@vellumai/cli 0.1.8 → 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,13 +1,12 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "vellum-cli": "./src/index.ts"
8
8
  },
9
9
  "scripts": {
10
- "bump": "bun run scripts/bump.ts",
11
10
  "lint": "eslint",
12
11
  "typecheck": "bunx tsc --noEmit"
13
12
  },
@@ -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,
@@ -85,6 +95,7 @@ export async function buildStartupScript(
85
95
  sshUser: string,
86
96
  anthropicApiKey: string,
87
97
  instanceName: string,
98
+ cloud: RemoteHost,
88
99
  ): Promise<string> {
89
100
  const platformUrl = process.env.VELLUM_ASSISTANT_PLATFORM_URL ?? "https://assistant.vellum.ai";
90
101
  const timestampRedirect = buildTimestampRedirect();
@@ -102,7 +113,7 @@ export async function buildStartupScript(
102
113
  );
103
114
  }
104
115
 
105
- const interfacesSeed = buildInterfacesSeed();
116
+ const interfacesSeed = await buildInterfacesSeed();
106
117
 
107
118
  return `#!/bin/bash
108
119
  set -e
@@ -115,6 +126,7 @@ ANTHROPIC_API_KEY=${anthropicApiKey}
115
126
  GATEWAY_RUNTIME_PROXY_ENABLED=true
116
127
  RUNTIME_PROXY_BEARER_TOKEN=${bearerToken}
117
128
  VELLUM_ASSISTANT_NAME=${instanceName}
129
+ VELLUM_CLOUD=${cloud}
118
130
  ${interfacesSeed}
119
131
  mkdir -p "\$HOME/.vellum"
120
132
  cat > "\$HOME/.vellum/.env" << DOTENV_EOF
@@ -123,6 +135,7 @@ GATEWAY_RUNTIME_PROXY_ENABLED=\$GATEWAY_RUNTIME_PROXY_ENABLED
123
135
  RUNTIME_PROXY_BEARER_TOKEN=\$RUNTIME_PROXY_BEARER_TOKEN
124
136
  INTERFACES_SEED_DIR=\$INTERFACES_SEED_DIR
125
137
  RUNTIME_HTTP_PORT=7821
138
+ VELLUM_CLOUD=\$VELLUM_CLOUD
126
139
  DOTENV_EOF
127
140
 
128
141
  mkdir -p "\$HOME/.vellum/workspace"
@@ -154,6 +167,7 @@ interface HatchArgs {
154
167
  detached: boolean;
155
168
  name: string | null;
156
169
  remote: RemoteHost;
170
+ daemonOnly: boolean;
157
171
  }
158
172
 
159
173
  function parseArgs(): HatchArgs {
@@ -162,11 +176,14 @@ function parseArgs(): HatchArgs {
162
176
  let detached = false;
163
177
  let name: string | null = null;
164
178
  let remote: RemoteHost = DEFAULT_REMOTE;
179
+ let daemonOnly = false;
165
180
 
166
181
  for (let i = 0; i < args.length; i++) {
167
182
  const arg = args[i];
168
183
  if (arg === "-d") {
169
184
  detached = true;
185
+ } else if (arg === "--daemon-only") {
186
+ daemonOnly = true;
170
187
  } else if (arg === "--name") {
171
188
  const next = args[i + 1];
172
189
  if (!next || next.startsWith("-")) {
@@ -189,13 +206,13 @@ function parseArgs(): HatchArgs {
189
206
  species = arg as Species;
190
207
  } else {
191
208
  console.error(
192
- `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("|")}>`,
193
210
  );
194
211
  process.exit(1);
195
212
  }
196
213
  }
197
214
 
198
- return { species, detached, name, remote };
215
+ return { species, detached, name, remote, daemonOnly };
199
216
  }
200
217
 
201
218
  export interface PollResult {
@@ -304,14 +321,16 @@ async function recoverFromCurlFailure(
304
321
  sshUser: string,
305
322
  account?: string,
306
323
  ): Promise<void> {
307
- if (!existsSync(INSTALL_SCRIPT_PATH)) {
308
- 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;
309
328
  }
310
329
 
311
330
  const scpArgs = [
312
331
  "compute",
313
332
  "scp",
314
- INSTALL_SCRIPT_PATH,
333
+ installScriptPath,
315
334
  `${instanceName}:${INSTALL_SCRIPT_REMOTE_PATH}`,
316
335
  `--zone=${zone}`,
317
336
  `--project=${project}`,
@@ -511,6 +530,7 @@ async function hatchGcp(
511
530
  sshUser,
512
531
  anthropicApiKey,
513
532
  instanceName,
533
+ "gcp",
514
534
  );
515
535
  const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
516
536
  writeFileSync(startupScriptPath, startupScript);
@@ -689,19 +709,25 @@ async function hatchCustom(
689
709
  sshUser,
690
710
  anthropicApiKey,
691
711
  instanceName,
712
+ "custom",
692
713
  );
693
714
  const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
694
715
  writeFileSync(startupScriptPath, startupScript);
695
716
 
696
717
  try {
697
- console.log("📋 Uploading install script to instance...");
698
- await exec("scp", [
699
- "-o", "StrictHostKeyChecking=no",
700
- "-o", "UserKnownHostsFile=/dev/null",
701
- "-o", "LogLevel=ERROR",
702
- INSTALL_SCRIPT_PATH,
703
- `${host}:${INSTALL_SCRIPT_REMOTE_PATH}`,
704
- ]);
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
+ }
705
731
 
706
732
  console.log("📋 Uploading startup script to instance...");
707
733
  const remoteStartupPath = `/tmp/${instanceName}-startup.sh`;
@@ -754,23 +780,118 @@ async function hatchCustom(
754
780
  }
755
781
  }
756
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
+
757
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
+
758
816
  const sourceDir = join(import.meta.dir, "..", "..", "..", "gateway");
759
- if (existsSync(sourceDir)) {
817
+ if (isGatewaySourceDir(sourceDir)) {
760
818
  return sourceDir;
761
819
  }
762
820
 
821
+ const cwdSourceDir = findGatewaySourceFromCwd();
822
+ if (cwdSourceDir) {
823
+ return cwdSourceDir;
824
+ }
825
+
763
826
  try {
764
827
  const pkgPath = _require.resolve("@vellumai/vellum-gateway/package.json");
765
828
  return dirname(pkgPath);
766
829
  } catch {
767
830
  throw new Error(
768
- "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.",
769
832
  );
770
833
  }
771
834
  }
772
835
 
773
- async function hatchLocal(species: Species, name: string | null): Promise<void> {
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> {
774
895
  const instanceName =
775
896
  name ?? process.env.VELLUM_ASSISTANT_NAME ?? `${species}-${generateRandomSuffix()}`;
776
897
 
@@ -778,33 +899,110 @@ async function hatchLocal(species: Species, name: string | null): Promise<void>
778
899
  console.log(` Species: ${species}`);
779
900
  console.log("");
780
901
 
781
- console.log("🔨 Starting local daemon...");
782
-
783
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.
784
905
  const daemonBinary = join(dirname(process.execPath), "vellum-daemon");
785
- const child = spawn(daemonBinary, [], {
786
- detached: true,
787
- stdio: "ignore",
788
- env: { ...process.env },
789
- });
790
- child.unref();
791
-
792
- const homeDir = process.env.HOME ?? userInfo().homedir;
793
- const socketPath = join(homeDir, ".vellum", "vellum.sock");
794
- const maxWait = 10000;
795
- const pollInterval = 100;
796
- let waited = 0;
797
- while (waited < maxWait) {
798
- if (existsSync(socketPath)) {
799
- 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");
800
984
  }
801
- await new Promise((r) => setTimeout(r, pollInterval));
802
- waited += pollInterval;
803
985
  }
804
- if (!existsSync(socketPath)) {
805
- 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");
806
1002
  }
807
1003
  } else {
1004
+ console.log("🔨 Starting local daemon...");
1005
+
808
1006
  // Source tree layout: cli/src/commands/ -> ../../.. -> repo root -> assistant/src/index.ts
809
1007
  const sourceTreeIndex = join(import.meta.dir, "..", "..", "..", "assistant", "src", "index.ts");
810
1008
  // bunx layout: @vellumai/cli/src/commands/ -> ../../../.. -> node_modules/ -> vellum/src/index.ts
@@ -848,44 +1046,77 @@ async function hatchLocal(species: Species, name: string | null): Promise<void>
848
1046
  });
849
1047
  }
850
1048
 
851
- console.log("🌐 Starting gateway...");
852
- const gatewayDir = resolveGatewayDir();
853
- const gateway = spawn("bun", ["run", "src/index.ts"], {
854
- cwd: gatewayDir,
855
- detached: true,
856
- stdio: "ignore",
857
- env: {
858
- ...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>,
859
1066
  GATEWAY_RUNTIME_PROXY_ENABLED: "true",
860
1067
  GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false",
861
- },
862
- });
863
- gateway.unref();
864
- 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;
865
1081
 
866
- 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");
867
1094
  const localEntry: AssistantEntry = {
868
1095
  assistantId: instanceName,
869
1096
  runtimeUrl,
1097
+ baseDataDir,
870
1098
  cloud: "local",
871
1099
  species,
872
1100
  hatchedAt: new Date().toISOString(),
873
1101
  };
874
- saveAssistantEntry(localEntry);
1102
+ if (!daemonOnly) {
1103
+ saveAssistantEntry(localEntry);
875
1104
 
876
- console.log("");
877
- console.log(`✅ Local assistant hatched!`);
878
- console.log("");
879
- console.log("Instance details:");
880
- console.log(` Name: ${instanceName}`);
881
- console.log(` Runtime: ${runtimeUrl}`);
882
- 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
+ }
883
1113
  }
884
1114
 
885
1115
  function getCliVersion(): string {
886
1116
  try {
887
- const pkgPath = join(import.meta.dir, "..", "..", "package.json");
888
- 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 };
889
1120
  return pkg.version ?? "unknown";
890
1121
  } catch {
891
1122
  return "unknown";
@@ -896,10 +1127,10 @@ export async function hatch(): Promise<void> {
896
1127
  const cliVersion = getCliVersion();
897
1128
  console.log(`@vellumai/cli v${cliVersion}`);
898
1129
 
899
- const { species, detached, name, remote } = parseArgs();
1130
+ const { species, detached, name, remote, daemonOnly } = parseArgs();
900
1131
 
901
1132
  if (remote === "local") {
902
- await hatchLocal(species, name);
1133
+ await hatchLocal(species, name, daemonOnly);
903
1134
  return;
904
1135
  }
905
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
@@ -426,6 +426,7 @@ export async function hatchAws(
426
426
  sshUser,
427
427
  anthropicApiKey,
428
428
  instanceName,
429
+ "aws",
429
430
  );
430
431
  const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
431
432
  writeFileSync(startupScriptPath, startupScript);
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
  }
package/scripts/bump.ts DELETED
@@ -1,36 +0,0 @@
1
- import { resolve } from "path";
2
-
3
- const CLI_DIR = resolve(import.meta.dirname, "..");
4
- const ASSISTANT_DIR = resolve(CLI_DIR, "../assistant");
5
-
6
- const cliPkgPath = resolve(CLI_DIR, "package.json");
7
- const assistantPkgPath = resolve(ASSISTANT_DIR, "package.json");
8
- const assistantLockPath = resolve(ASSISTANT_DIR, "bun.lock");
9
-
10
- function bumpPatch(version: string): string {
11
- const parts = version.split(".");
12
- parts[2] = String(Number(parts[2]) + 1);
13
- return parts.join(".");
14
- }
15
-
16
- const cliPkg = await Bun.file(cliPkgPath).json();
17
- const oldVersion: string = cliPkg.version;
18
- const newVersion = bumpPatch(oldVersion);
19
- cliPkg.version = newVersion;
20
- await Bun.write(cliPkgPath, JSON.stringify(cliPkg, null, 2) + "\n");
21
-
22
- const assistantPkg = await Bun.file(assistantPkgPath).json();
23
- assistantPkg.dependencies["@vellumai/cli"] = newVersion;
24
- assistantPkg.version = bumpPatch(assistantPkg.version);
25
- await Bun.write(assistantPkgPath, JSON.stringify(assistantPkg, null, 2) + "\n");
26
-
27
- let lockContent = await Bun.file(assistantLockPath).text();
28
- lockContent = lockContent.replace(
29
- /"@vellumai\/cli": "[^"]*"/g,
30
- `"@vellumai/cli": "${newVersion}"`
31
- );
32
- lockContent = lockContent.replace(
33
- /@vellumai\/cli@\d+\.\d+\.\d+/g,
34
- `@vellumai/cli@${newVersion}`
35
- );
36
- await Bun.write(assistantLockPath, lockContent);