@vellumai/cli 0.4.25 → 0.4.29

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
@@ -1,5 +1,6 @@
1
1
  import { execFileSync, spawn } from "child_process";
2
2
  import {
3
+ closeSync,
3
4
  existsSync,
4
5
  mkdirSync,
5
6
  readFileSync,
@@ -78,6 +79,18 @@ function findGatewaySourceFromCwd(): string | undefined {
78
79
  }
79
80
  }
80
81
 
82
+ function isOutboundProxySourceDir(dir: string): boolean {
83
+ const pkgPath = join(dir, "package.json");
84
+ if (!existsSync(pkgPath) || !existsSync(join(dir, "src", "main.ts")))
85
+ return false;
86
+ try {
87
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
88
+ return pkg.name === "@vellumai/outbound-proxy";
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
81
94
  function resolveAssistantIndexPath(): string | undefined {
82
95
  // Source tree layout: cli/src/lib/ -> ../../.. -> repo root -> assistant/src/index.ts
83
96
  const sourceTreeIndex = join(
@@ -153,6 +166,49 @@ function resolveDaemonMainPath(assistantIndex: string): string {
153
166
  }
154
167
 
155
168
  async function startDaemonFromSource(assistantIndex: string): Promise<void> {
169
+ const daemonMainPath = resolveDaemonMainPath(assistantIndex);
170
+
171
+ const vellumDir = join(homedir(), ".vellum");
172
+ mkdirSync(vellumDir, { recursive: true });
173
+
174
+ const pidFile = join(vellumDir, "vellum.pid");
175
+ const socketFile = join(vellumDir, "vellum.sock");
176
+
177
+ // --- Lifecycle guard: prevent split-brain daemon state ---
178
+ if (existsSync(pidFile)) {
179
+ try {
180
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
181
+ if (!isNaN(pid)) {
182
+ try {
183
+ process.kill(pid, 0);
184
+ console.log(` Assistant already running (pid ${pid})\n`);
185
+ return;
186
+ } catch {
187
+ try {
188
+ unlinkSync(pidFile);
189
+ } catch {}
190
+ }
191
+ }
192
+ } catch {}
193
+ }
194
+
195
+ if (await isSocketResponsive(socketFile)) {
196
+ const ownerPid = findSocketOwnerPid(socketFile);
197
+ if (ownerPid) {
198
+ writeFileSync(pidFile, String(ownerPid), "utf-8");
199
+ console.log(
200
+ ` Assistant socket is responsive (pid ${ownerPid}) — skipping restart\n`,
201
+ );
202
+ } else {
203
+ console.log(" Assistant socket is responsive — skipping restart\n");
204
+ }
205
+ return;
206
+ }
207
+
208
+ try {
209
+ unlinkSync(socketFile);
210
+ } catch {}
211
+
156
212
  const env: Record<string, string | undefined> = {
157
213
  ...process.env,
158
214
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
@@ -163,28 +219,30 @@ async function startDaemonFromSource(assistantIndex: string): Promise<void> {
163
219
  process.env.VELLUM_DAEMON_TCP_ENABLED || "1";
164
220
  }
165
221
 
166
- const child = spawn("bun", ["run", assistantIndex, "daemon", "start"], {
167
- stdio: "inherit",
222
+ // Use fd inheritance instead of pipes so the daemon's stdout/stderr survive
223
+ // after the parent (hatch) exits. Bun does not ignore SIGPIPE, so piped
224
+ // stdio would kill the daemon on its first write after the parent closes.
225
+ const logFd = openLogFile("hatch.log");
226
+ const child = spawn("bun", ["run", daemonMainPath], {
227
+ detached: true,
228
+ stdio: ["ignore", logFd, logFd],
168
229
  env,
169
230
  });
231
+ if (typeof logFd === "number") closeSync(logFd);
232
+ child.unref();
170
233
 
171
- await new Promise<void>((resolve, reject) => {
172
- child.on("close", (code) => {
173
- if (code === 0) {
174
- resolve();
175
- } else {
176
- reject(new Error(`Daemon start exited with code ${code}`));
177
- }
178
- });
179
- child.on("error", reject);
180
- });
234
+ if (child.pid) {
235
+ writeFileSync(pidFile, String(child.pid), "utf-8");
236
+ }
181
237
  }
182
238
 
183
239
  // NOTE: startDaemonWatchFromSource() is the CLI-side watch-mode daemon
184
240
  // launcher. Its lifecycle guards should eventually converge with
185
241
  // assistant/src/daemon/daemon-control.ts::startDaemon which is the
186
242
  // assistant-side equivalent.
187
- async function startDaemonWatchFromSource(assistantIndex: string): Promise<void> {
243
+ async function startDaemonWatchFromSource(
244
+ assistantIndex: string,
245
+ ): Promise<void> {
188
246
  const mainPath = resolveDaemonMainPath(assistantIndex);
189
247
  if (!existsSync(mainPath)) {
190
248
  throw new Error(`Daemon main.ts not found at ${mainPath}`);
@@ -204,11 +262,13 @@ async function startDaemonWatchFromSource(assistantIndex: string): Promise<void>
204
262
  if (!isNaN(pid)) {
205
263
  try {
206
264
  process.kill(pid, 0); // Check if alive
207
- console.log(` Daemon already running (pid ${pid})\n`);
265
+ console.log(` Assistant already running (pid ${pid})\n`);
208
266
  return;
209
267
  } catch {
210
268
  // Process doesn't exist, clean up stale PID file
211
- try { unlinkSync(pidFile); } catch {}
269
+ try {
270
+ unlinkSync(pidFile);
271
+ } catch {}
212
272
  }
213
273
  }
214
274
  } catch {}
@@ -221,16 +281,18 @@ async function startDaemonWatchFromSource(assistantIndex: string): Promise<void>
221
281
  if (ownerPid) {
222
282
  writeFileSync(pidFile, String(ownerPid), "utf-8");
223
283
  console.log(
224
- ` Daemon socket is responsive (pid ${ownerPid}) — skipping restart\n`,
284
+ ` Assistant socket is responsive (pid ${ownerPid}) — skipping restart\n`,
225
285
  );
226
286
  } else {
227
- console.log(" Daemon socket is responsive — skipping restart\n");
287
+ console.log(" Assistant socket is responsive — skipping restart\n");
228
288
  }
229
289
  return;
230
290
  }
231
291
 
232
292
  // Socket is unresponsive or missing — safe to clean up and start fresh.
233
- try { unlinkSync(socketFile); } catch {}
293
+ try {
294
+ unlinkSync(socketFile);
295
+ } catch {}
234
296
 
235
297
  const env: Record<string, string | undefined> = {
236
298
  ...process.env,
@@ -251,7 +313,7 @@ async function startDaemonWatchFromSource(assistantIndex: string): Promise<void>
251
313
  writeFileSync(pidFile, String(daemonPid), "utf-8");
252
314
  }
253
315
 
254
- console.log(" Daemon started in watch mode (bun --watch)");
316
+ console.log(" Assistant started in watch mode (bun --watch)");
255
317
  }
256
318
 
257
319
  function resolveGatewayDir(): string {
@@ -292,6 +354,21 @@ function resolveGatewayDir(): string {
292
354
  }
293
355
  }
294
356
 
357
+ function resolveOutboundProxyDir(): string | undefined {
358
+ // Compiled binary: outbound-proxy/ bundled adjacent to the CLI executable.
359
+ const binProxy = join(dirname(process.execPath), "outbound-proxy");
360
+ if (isOutboundProxySourceDir(binProxy)) {
361
+ return binProxy;
362
+ }
363
+
364
+ try {
365
+ const pkgPath = _require.resolve("@vellumai/outbound-proxy/package.json");
366
+ return dirname(pkgPath);
367
+ } catch {
368
+ return undefined;
369
+ }
370
+ }
371
+
295
372
  function normalizeIngressUrl(value: unknown): string | undefined {
296
373
  if (typeof value !== "string") return undefined;
297
374
  const normalized = value.trim().replace(/\/+$/, "");
@@ -528,7 +605,7 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
528
605
  try {
529
606
  process.kill(pid, 0); // Check if alive
530
607
  daemonAlive = true;
531
- console.log(` Daemon already running (pid ${pid})\n`);
608
+ console.log(` Assistant already running (pid ${pid})\n`);
532
609
  } catch {
533
610
  // Process doesn't exist, clean up stale PID file
534
611
  try {
@@ -550,10 +627,10 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
550
627
  if (ownerPid) {
551
628
  writeFileSync(pidFile, String(ownerPid), "utf-8");
552
629
  console.log(
553
- ` Daemon socket is responsive (pid ${ownerPid}) — skipping restart\n`,
630
+ ` Assistant socket is responsive (pid ${ownerPid}) — skipping restart\n`,
554
631
  );
555
632
  } else {
556
- console.log(" Daemon socket is responsive — skipping restart\n");
633
+ console.log(" Assistant socket is responsive — skipping restart\n");
557
634
  }
558
635
  return;
559
636
  }
@@ -563,7 +640,7 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
563
640
  unlinkSync(socketFile);
564
641
  } catch {}
565
642
 
566
- console.log("🔨 Starting daemon...");
643
+ console.log("🔨 Starting assistant...");
567
644
 
568
645
  // Ensure ~/.vellum/ exists for PID/socket files
569
646
  mkdirSync(vellumDir, { recursive: true });
@@ -625,7 +702,7 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
625
702
  const assistantIndex = resolveAssistantIndexPath();
626
703
  if (assistantIndex) {
627
704
  console.log(
628
- " Bundled daemon socket not ready after 60s — falling back to source daemon...",
705
+ " Bundled assistant socket not ready after 60s — falling back to source assistant...",
629
706
  );
630
707
  // Kill the bundled daemon to avoid two processes competing for the same socket/port
631
708
  await stopProcessByPidFile(pidFile, "bundled daemon", [socketFile]);
@@ -639,14 +716,14 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
639
716
  }
640
717
 
641
718
  if (socketReady) {
642
- console.log(" Daemon socket ready\n");
719
+ console.log(" Assistant socket ready\n");
643
720
  } else {
644
721
  console.log(
645
- " ⚠️ Daemon socket did not appear within 60s — continuing anyway\n",
722
+ " ⚠️ Assistant socket did not appear within 60s — continuing anyway\n",
646
723
  );
647
724
  }
648
725
  } else {
649
- console.log("🔨 Starting local daemon...");
726
+ console.log("🔨 Starting local assistant...");
650
727
 
651
728
  const assistantIndex = resolveAssistantIndexPath();
652
729
  if (!assistantIndex) {
@@ -662,17 +739,33 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
662
739
  const socketFile = join(vellumDir, "vellum.sock");
663
740
  const socketReady = await waitForSocketFile(socketFile, 60000);
664
741
  if (socketReady) {
665
- console.log(" Daemon socket ready\n");
742
+ console.log(" Assistant socket ready\n");
666
743
  } else {
667
- console.log(" ⚠️ Daemon socket did not appear within 60s — continuing anyway\n");
744
+ console.log(
745
+ " ⚠️ Assistant socket did not appear within 60s — continuing anyway\n",
746
+ );
668
747
  }
669
748
  } else {
670
749
  await startDaemonFromSource(assistantIndex);
750
+
751
+ const vellumDir = join(homedir(), ".vellum");
752
+ const socketFile = join(vellumDir, "vellum.sock");
753
+ const socketReady = await waitForSocketFile(socketFile, 60000);
754
+ if (socketReady) {
755
+ console.log(" Assistant socket ready\n");
756
+ } else {
757
+ console.log(
758
+ " ⚠️ Assistant socket did not appear within 60s — continuing anyway\n",
759
+ );
760
+ }
671
761
  }
672
762
  }
673
763
  }
674
764
 
675
- export async function startGateway(assistantId?: string, watch: boolean = false): Promise<string> {
765
+ export async function startGateway(
766
+ assistantId?: string,
767
+ watch: boolean = false,
768
+ ): Promise<string> {
676
769
  const publicUrl = await discoverPublicUrl();
677
770
  if (publicUrl) {
678
771
  console.log(` Public URL: ${publicUrl}`);
@@ -856,10 +949,130 @@ export async function startGateway(assistantId?: string, watch: boolean = false)
856
949
  return gatewayUrl;
857
950
  }
858
951
 
952
+ export async function startOutboundProxy(
953
+ watch: boolean = false,
954
+ ): Promise<void> {
955
+ const proxyDir = resolveOutboundProxyDir();
956
+ if (!proxyDir) {
957
+ console.log(" ⚠️ Outbound proxy not found — skipping");
958
+ return;
959
+ }
960
+
961
+ console.log("🔒 Starting outbound proxy...");
962
+
963
+ const vellumDir = join(homedir(), ".vellum");
964
+ mkdirSync(vellumDir, { recursive: true });
965
+
966
+ const pidFile = join(vellumDir, "outbound-proxy.pid");
967
+
968
+ // Check if already running
969
+ if (existsSync(pidFile)) {
970
+ try {
971
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
972
+ if (!isNaN(pid)) {
973
+ try {
974
+ process.kill(pid, 0);
975
+ console.log(` Outbound proxy already running (pid ${pid})\n`);
976
+ return;
977
+ } catch {
978
+ try {
979
+ unlinkSync(pidFile);
980
+ } catch {}
981
+ }
982
+ }
983
+ } catch {}
984
+ }
985
+
986
+ const proxyEnv: Record<string, string> = {
987
+ ...(process.env as Record<string, string>),
988
+ PROXY_PORT: process.env.PROXY_PORT || "7829",
989
+ PROXY_HEALTH_PORT: process.env.PROXY_HEALTH_PORT || "7828",
990
+ };
991
+
992
+ const proxyLogFd = openLogFile("hatch.log");
993
+
994
+ let proxy;
995
+ if (process.env.VELLUM_DESKTOP_APP && !watch) {
996
+ const proxyBinary = join(
997
+ dirname(process.execPath),
998
+ "vellum-outbound-proxy",
999
+ );
1000
+ if (!existsSync(proxyBinary)) {
1001
+ console.log(
1002
+ " ⚠️ Outbound proxy binary not found — falling back to source",
1003
+ );
1004
+ const bunArgs = watch
1005
+ ? ["--watch", "run", "src/main.ts"]
1006
+ : ["run", "src/main.ts"];
1007
+ proxy = spawn("bun", bunArgs, {
1008
+ cwd: proxyDir,
1009
+ detached: true,
1010
+ stdio: ["ignore", "pipe", "pipe"],
1011
+ env: proxyEnv,
1012
+ });
1013
+ } else {
1014
+ proxy = spawn(proxyBinary, [], {
1015
+ detached: true,
1016
+ stdio: ["ignore", "pipe", "pipe"],
1017
+ env: proxyEnv,
1018
+ });
1019
+ }
1020
+ } else {
1021
+ const bunArgs = watch
1022
+ ? ["--watch", "run", "src/main.ts"]
1023
+ : ["run", "src/main.ts"];
1024
+ proxy = spawn("bun", bunArgs, {
1025
+ cwd: proxyDir,
1026
+ detached: true,
1027
+ stdio: ["ignore", "pipe", "pipe"],
1028
+ env: proxyEnv,
1029
+ });
1030
+ }
1031
+
1032
+ pipeToLogFile(proxy, proxyLogFd, "outbound-proxy");
1033
+ proxy.unref();
1034
+
1035
+ if (proxy.pid) {
1036
+ writeFileSync(pidFile, String(proxy.pid), "utf-8");
1037
+ }
1038
+
1039
+ if (watch) {
1040
+ console.log(" Outbound proxy started in watch mode (bun --watch)");
1041
+ }
1042
+
1043
+ // Wait for the health endpoint to respond
1044
+ const healthPort = Number(process.env.PROXY_HEALTH_PORT) || 7828;
1045
+ const start = Date.now();
1046
+ const timeoutMs = 15000;
1047
+ let ready = false;
1048
+ while (Date.now() - start < timeoutMs) {
1049
+ try {
1050
+ const res = await fetch(`http://localhost:${healthPort}/healthz`, {
1051
+ signal: AbortSignal.timeout(2000),
1052
+ });
1053
+ if (res.ok) {
1054
+ ready = true;
1055
+ break;
1056
+ }
1057
+ } catch {
1058
+ // Not ready yet
1059
+ }
1060
+ await new Promise((r) => setTimeout(r, 250));
1061
+ }
1062
+
1063
+ if (!ready) {
1064
+ console.warn(
1065
+ " ⚠️ Outbound proxy started but health check did not respond within 15s",
1066
+ );
1067
+ }
1068
+
1069
+ console.log("✅ Outbound proxy started\n");
1070
+ }
1071
+
859
1072
  /**
860
- * Stop any locally-running daemon and gateway processes and clean up
861
- * PID/socket files. Called when hatch fails partway through so we don't
862
- * leave orphaned processes with no lock file entry.
1073
+ * Stop any locally-running daemon, gateway, and outbound-proxy processes
1074
+ * and clean up PID/socket files. Called when hatch fails partway through
1075
+ * so we don't leave orphaned processes with no lock file entry.
863
1076
  */
864
1077
  export async function stopLocalProcesses(): Promise<void> {
865
1078
  const vellumDir = join(homedir(), ".vellum");
@@ -869,4 +1082,7 @@ export async function stopLocalProcesses(): Promise<void> {
869
1082
 
870
1083
  const gatewayPidFile = join(vellumDir, "gateway.pid");
871
1084
  await stopProcessByPidFile(gatewayPidFile, "gateway");
1085
+
1086
+ const outboundProxyPidFile = join(vellumDir, "outbound-proxy.pid");
1087
+ await stopProcessByPidFile(outboundProxyPidFile, "outbound-proxy");
872
1088
  }
package/src/lib/ngrok.ts CHANGED
@@ -158,9 +158,7 @@ export async function runNgrokTunnel(): Promise<void> {
158
158
  console.error(" macOS: brew install ngrok/ngrok/ngrok");
159
159
  console.error(" Linux: sudo snap install ngrok");
160
160
  console.error("");
161
- console.error(
162
- "Then authenticate: ngrok config add-authtoken <your-token>",
163
- );
161
+ console.error("Then authenticate: ngrok config add-authtoken <your-token>");
164
162
  console.error(
165
163
  " Get your token at: https://dashboard.ngrok.com/get-started/your-authtoken",
166
164
  );
@@ -5,7 +5,9 @@
5
5
 
6
6
  export async function buildOpenclawRuntimeServer(): Promise<string> {
7
7
  try {
8
- const serverSource = await Bun.file(import.meta.dir + "/../adapters/openclaw-http-server.ts").text();
8
+ const serverSource = await Bun.file(
9
+ import.meta.dir + "/../adapters/openclaw-http-server.ts",
10
+ ).text();
9
11
 
10
12
  return `
11
13
  cat > /opt/openclaw-runtime-server.ts << 'RUNTIME_SERVER_EOF'
@@ -17,7 +19,10 @@ nohup bun run /opt/openclaw-runtime-server.ts >> "\$HOME/.vellum/http-gateway.lo
17
19
  echo "OpenClaw runtime server started (PID: \$!)"
18
20
  `;
19
21
  } catch (err) {
20
- console.warn("⚠️ Could not embed openclaw runtime server (expected in compiled binary without --embed):", (err as Error).message);
22
+ console.warn(
23
+ "⚠️ Could not embed openclaw runtime server (expected in compiled binary without --embed):",
24
+ (err as Error).message,
25
+ );
21
26
  return "# openclaw-runtime-server: skipped (source files not available in compiled binary)";
22
27
  }
23
28
  }
@@ -1,4 +1,11 @@
1
- import { chmodSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from "fs";
1
+ import {
2
+ chmodSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ unlinkSync,
6
+ existsSync,
7
+ mkdirSync,
8
+ } from "fs";
2
9
  import { homedir } from "os";
3
10
  import { join, dirname } from "path";
4
11
 
@@ -63,10 +70,16 @@ export async function fetchCurrentUser(token: string): Promise<PlatformUser> {
63
70
  });
64
71
 
65
72
  if (!response.ok) {
66
- if (response.status === 401 || response.status === 403 || response.status === 410) {
73
+ if (
74
+ response.status === 401 ||
75
+ response.status === 403 ||
76
+ response.status === 410
77
+ ) {
67
78
  throw new Error("Invalid or expired token. Please login again.");
68
79
  }
69
- throw new Error(`Platform API error: ${response.status} ${response.statusText}`);
80
+ throw new Error(
81
+ `Platform API error: ${response.status} ${response.statusText}`,
82
+ );
70
83
  }
71
84
 
72
85
  const body = (await response.json()) as AllauthSessionResponse;
@@ -26,14 +26,22 @@ export function openLogFile(name: string): number | "ignore" {
26
26
  /** Close a file descriptor returned by openLogFile (no-op for "ignore"). */
27
27
  export function closeLogFile(fd: number | "ignore"): void {
28
28
  if (typeof fd === "number") {
29
- try { closeSync(fd); } catch { /* best-effort */ }
29
+ try {
30
+ closeSync(fd);
31
+ } catch {
32
+ /* best-effort */
33
+ }
30
34
  }
31
35
  }
32
36
 
33
37
  /** Write a string to a file descriptor returned by openLogFile (no-op for "ignore"). */
34
38
  export function writeToLogFile(fd: number | "ignore", msg: string): void {
35
39
  if (typeof fd === "number") {
36
- try { writeSync(fd, msg); } catch { /* best-effort */ }
40
+ try {
41
+ writeSync(fd, msg);
42
+ } catch {
43
+ /* best-effort */
44
+ }
37
45
  }
38
46
  }
39
47
 
@@ -41,7 +49,11 @@ export function writeToLogFile(fd: number | "ignore", msg: string): void {
41
49
  * prefixing each line with a tag (e.g. "[daemon]" or "[gateway]").
42
50
  * Streams are unref'd so they don't prevent the parent from exiting.
43
51
  * The fd is closed automatically when both streams end. */
44
- export function pipeToLogFile(child: ChildProcess, fd: number | "ignore", tag: string): void {
52
+ export function pipeToLogFile(
53
+ child: ChildProcess,
54
+ fd: number | "ignore",
55
+ tag: string,
56
+ ): void {
45
57
  if (fd === "ignore") return;
46
58
  const numFd: number = fd;
47
59
  const tagLabel = `[${tag}]`;
@@ -51,7 +63,11 @@ export function pipeToLogFile(child: ChildProcess, fd: number | "ignore", tag: s
51
63
  function onDone() {
52
64
  ended++;
53
65
  if (ended >= streams.length) {
54
- try { closeSync(numFd); } catch { /* best-effort */ }
66
+ try {
67
+ closeSync(numFd);
68
+ } catch {
69
+ /* best-effort */
70
+ }
55
71
  }
56
72
  }
57
73
 
@@ -65,7 +81,11 @@ export function pipeToLogFile(child: ChildProcess, fd: number | "ignore", tag: s
65
81
  if (i === lines.length - 1 && lines[i] === "") break;
66
82
  const nl = i < lines.length - 1 ? "\n" : "";
67
83
  const prefix = `${new Date().toISOString()} ${tagLabel} `;
68
- try { writeSync(numFd, prefix + lines[i] + nl); } catch { /* best-effort */ }
84
+ try {
85
+ writeSync(numFd, prefix + lines[i] + nl);
86
+ } catch {
87
+ /* best-effort */
88
+ }
69
89
  }
70
90
  });
71
91
  stream.on("end", onDone);