@vellumai/cli 0.4.7 → 0.4.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,10 +34,15 @@ ensure_git() {
34
34
  # confirmation.
35
35
  touch /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
36
36
  local clt_package
37
+ # softwareupdate -l output has two relevant lines per update:
38
+ # * Label: Command Line Tools for Xcode-16.0 <-- label (what -i expects)
39
+ # Title: Command Line Tools for Xcode, ... <-- description
40
+ # We need the label, which is on lines starting with "* ".
41
+ # Use the same parsing approach as Homebrew's installer.
37
42
  clt_package=$(softwareupdate -l 2>/dev/null \
38
- | grep -o '.*Command Line Tools.*' \
39
- | grep -v "^\*" \
40
- | sed 's/^ *//' \
43
+ | grep -B 1 -E 'Command Line Tools' \
44
+ | awk -F'*' '/^\*/{print $2}' \
45
+ | sed -e 's/^ Label: //' -e 's/^ *//' \
41
46
  | sort -V \
42
47
  | tail -1)
43
48
 
@@ -504,11 +504,40 @@ function installCLISymlink(): void {
504
504
  console.log(` ⚠ Could not create symlink for vellum CLI (tried ${preferredPath} and ${fallbackPath})`);
505
505
  }
506
506
 
507
+ async function waitForDaemonReady(runtimeUrl: string, bearerToken: string | undefined, timeoutMs = 15000): Promise<boolean> {
508
+ const start = Date.now();
509
+ const pollInterval = 1000;
510
+ while (Date.now() - start < timeoutMs) {
511
+ try {
512
+ const res = await fetch(`${runtimeUrl}/v1/health`, {
513
+ method: "GET",
514
+ headers: bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {},
515
+ signal: AbortSignal.timeout(2000),
516
+ });
517
+ if (res.ok) return true;
518
+ } catch {
519
+ // Daemon not ready yet
520
+ }
521
+ await new Promise((r) => setTimeout(r, pollInterval));
522
+ }
523
+ return false;
524
+ }
525
+
507
526
  async function displayPairingQRCode(runtimeUrl: string, bearerToken: string | undefined): Promise<void> {
508
527
  try {
509
528
  const pairingRequestId = randomUUID();
510
529
  const pairingSecret = randomBytes(32).toString("hex");
511
530
 
531
+ // The daemon's HTTP server may not be fully ready even though the gateway
532
+ // health check passed (the gateway is up, but the upstream daemon HTTP
533
+ // endpoint it proxies to may still be initializing). Poll the daemon's
534
+ // health endpoint through the gateway to ensure it's reachable.
535
+ const daemonReady = await waitForDaemonReady(runtimeUrl, bearerToken);
536
+ if (!daemonReady) {
537
+ console.warn("⚠ Daemon health check did not pass within 15s. Run `vellum pair` to try again.\n");
538
+ return;
539
+ }
540
+
512
541
  const registerRes = await fetch(`${runtimeUrl}/pairing/register`, {
513
542
  method: "POST",
514
543
  headers: {
@@ -10,6 +10,7 @@ import { retireInstance as retireGcpInstance } from "../lib/gcp";
10
10
  import { stopProcessByPidFile } from "../lib/process";
11
11
  import { getArchivePath, getMetadataPath } from "../lib/retire-archive";
12
12
  import { exec } from "../lib/step-runner";
13
+ import { openLogFile, closeLogFile, writeToLogFile } from "../lib/xdg-log";
13
14
 
14
15
  function resolveCloud(entry: AssistantEntry): string {
15
16
  if (entry.cloud) {
@@ -120,7 +121,42 @@ function parseSource(): string | undefined {
120
121
  return undefined;
121
122
  }
122
123
 
124
+ /** Patch console methods to also append output to the given log file descriptor. */
125
+ function teeConsoleToLogFile(fd: number | "ignore"): void {
126
+ if (fd === "ignore") return;
127
+
128
+ const origLog = console.log.bind(console);
129
+ const origWarn = console.warn.bind(console);
130
+ const origError = console.error.bind(console);
131
+
132
+ const timestamp = () => new Date().toISOString();
133
+
134
+ console.log = (...args: unknown[]) => {
135
+ origLog(...args);
136
+ writeToLogFile(fd, `[${timestamp()}] ${args.map(String).join(" ")}\n`);
137
+ };
138
+ console.warn = (...args: unknown[]) => {
139
+ origWarn(...args);
140
+ writeToLogFile(fd, `[${timestamp()}] WARN: ${args.map(String).join(" ")}\n`);
141
+ };
142
+ console.error = (...args: unknown[]) => {
143
+ origError(...args);
144
+ writeToLogFile(fd, `[${timestamp()}] ERROR: ${args.map(String).join(" ")}\n`);
145
+ };
146
+ }
147
+
123
148
  export async function retire(): Promise<void> {
149
+ const logFd = process.env.VELLUM_DESKTOP_APP ? openLogFile("retire.log") : "ignore";
150
+ teeConsoleToLogFile(logFd);
151
+
152
+ try {
153
+ await retireInner();
154
+ } finally {
155
+ closeLogFile(logFd);
156
+ }
157
+ }
158
+
159
+ async function retireInner(): Promise<void> {
124
160
  const args = process.argv.slice(3);
125
161
  if (args.includes("--help") || args.includes("-h")) {
126
162
  console.log("Usage: vellum retire <name> [--source <source>]");
@@ -1884,46 +1884,48 @@ function ChatApp({
1884
1884
  healthStatus={healthStatus}
1885
1885
  />
1886
1886
 
1887
- {visibleWindow.hiddenAbove > 0 ? (
1888
- <Text dimColor>
1889
- {"\u2191"} {visibleWindow.hiddenAbove} more above (Shift+\u2191/Cmd+\u2191)
1890
- </Text>
1891
- ) : null}
1892
-
1893
- {visibleWindow.items.map((item, i) => {
1894
- const feedIndex = visibleWindow.startIndex + i;
1895
- if (isRuntimeMessage(item)) {
1896
- return (
1897
- <Box key={feedIndex} flexDirection="column" marginBottom={1}>
1898
- <MessageDisplay msg={item} />
1899
- </Box>
1900
- );
1901
- }
1902
- if (item.type === "status") {
1903
- return (
1904
- <Text key={feedIndex} color={item.color as "green" | "yellow" | "red" | undefined}>
1905
- {item.text}
1906
- </Text>
1907
- );
1908
- }
1909
- if (item.type === "help") {
1910
- return <HelpDisplay key={feedIndex} />;
1911
- }
1912
- if (item.type === "error") {
1913
- return (
1914
- <Text key={feedIndex} color="red">
1915
- {item.text}
1916
- </Text>
1917
- );
1918
- }
1919
- return null;
1920
- })}
1887
+ <Box flexDirection="column" flexGrow={1} overflow="hidden">
1888
+ {visibleWindow.hiddenAbove > 0 ? (
1889
+ <Text dimColor>
1890
+ {"\u2191"} {visibleWindow.hiddenAbove} more above (Shift+\u2191/Cmd+\u2191)
1891
+ </Text>
1892
+ ) : null}
1893
+
1894
+ {visibleWindow.items.map((item, i) => {
1895
+ const feedIndex = visibleWindow.startIndex + i;
1896
+ if (isRuntimeMessage(item)) {
1897
+ return (
1898
+ <Box key={feedIndex} flexDirection="column" marginBottom={1}>
1899
+ <MessageDisplay msg={item} />
1900
+ </Box>
1901
+ );
1902
+ }
1903
+ if (item.type === "status") {
1904
+ return (
1905
+ <Text key={feedIndex} color={item.color as "green" | "yellow" | "red" | undefined}>
1906
+ {item.text}
1907
+ </Text>
1908
+ );
1909
+ }
1910
+ if (item.type === "help") {
1911
+ return <HelpDisplay key={feedIndex} />;
1912
+ }
1913
+ if (item.type === "error") {
1914
+ return (
1915
+ <Text key={feedIndex} color="red">
1916
+ {item.text}
1917
+ </Text>
1918
+ );
1919
+ }
1920
+ return null;
1921
+ })}
1921
1922
 
1922
- {visibleWindow.hiddenBelow > 0 ? (
1923
- <Text dimColor>
1924
- {"\u2193"} {visibleWindow.hiddenBelow} more below (Shift+\u2193/Cmd+\u2193)
1925
- </Text>
1926
- ) : null}
1923
+ {visibleWindow.hiddenBelow > 0 ? (
1924
+ <Text dimColor>
1925
+ {"\u2193"} {visibleWindow.hiddenBelow} more below (Shift+\u2193/Cmd+\u2193)
1926
+ </Text>
1927
+ ) : null}
1928
+ </Box>
1927
1929
 
1928
1930
  {spinnerText ? <SpinnerDisplay text={spinnerText} /> : null}
1929
1931
 
package/src/lib/local.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync, spawn } from "child_process";
2
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3
3
  import { createRequire } from "module";
4
4
  import { createConnection } from "net";
5
5
  import { homedir } from "os";
@@ -8,35 +8,10 @@ import { dirname, join } from "path";
8
8
  import { loadLatestAssistant } from "./assistant-config.js";
9
9
  import { GATEWAY_PORT } from "./constants.js";
10
10
  import { stopProcessByPidFile } from "./process.js";
11
+ import { openLogFile, closeLogFile } from "./xdg-log.js";
11
12
 
12
13
  const _require = createRequire(import.meta.url);
13
14
 
14
- /** Returns the XDG-compatible log directory for Vellum hatch logs. */
15
- function getHatchLogDir(): string {
16
- const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
17
- return join(configHome, "vellum", "logs");
18
- }
19
-
20
- /** Open (or create) a log file in append mode, returning the file descriptor.
21
- * Creates the parent directory if it doesn't exist. Returns "ignore" if the
22
- * directory or file cannot be created (permissions, read-only filesystem, etc.)
23
- * so that spawn falls back to discarding output instead of aborting startup. */
24
- function openHatchLogFile(name: string): number | "ignore" {
25
- try {
26
- const dir = getHatchLogDir();
27
- mkdirSync(dir, { recursive: true });
28
- return openSync(join(dir, name), "a");
29
- } catch {
30
- return "ignore";
31
- }
32
- }
33
-
34
- /** Close a file descriptor returned by openHatchLogFile (no-op for "ignore"). */
35
- function closeHatchLogFile(fd: number | "ignore"): void {
36
- if (typeof fd === "number") {
37
- try { closeSync(fd); } catch { /* best-effort */ }
38
- }
39
- }
40
15
 
41
16
  function isAssistantSourceDir(dir: string): boolean {
42
17
  const pkgPath = join(dir, "package.json");
@@ -307,6 +282,12 @@ async function discoverPublicUrl(): Promise<string | undefined> {
307
282
  // metadata service not reachable
308
283
  }
309
284
 
285
+ // For custom hardware or when cloud-specific metadata didn't resolve,
286
+ // fall back to a public IP discovery service.
287
+ if (!externalIp) {
288
+ externalIp = await discoverPublicIpFallback();
289
+ }
290
+
310
291
  if (externalIp) {
311
292
  console.log(` Discovered external IP: ${externalIp}`);
312
293
  return `http://${externalIp}:${GATEWAY_PORT}`;
@@ -314,6 +295,32 @@ async function discoverPublicUrl(): Promise<string | undefined> {
314
295
  return undefined;
315
296
  }
316
297
 
298
+ /** Try to discover the machine's public IP using external services.
299
+ * Attempts multiple providers for resilience. */
300
+ async function discoverPublicIpFallback(): Promise<string | undefined> {
301
+ const services = [
302
+ "https://api.ipify.org",
303
+ "https://ifconfig.me/ip",
304
+ "https://icanhazip.com",
305
+ ];
306
+
307
+ for (const url of services) {
308
+ try {
309
+ const resp = await fetch(url, { signal: AbortSignal.timeout(3000) });
310
+ if (resp.ok) {
311
+ const ip = (await resp.text()).trim();
312
+ // Basic validation: must look like an IPv4 or IPv6 address
313
+ if (ip && /^[\d.:a-fA-F]+$/.test(ip)) {
314
+ return ip;
315
+ }
316
+ }
317
+ } catch {
318
+ // Service unreachable, try the next one
319
+ }
320
+ }
321
+ return undefined;
322
+ }
323
+
317
324
  export async function startLocalDaemon(): Promise<void> {
318
325
  if (process.env.VELLUM_DESKTOP_APP) {
319
326
  // When running inside the desktop app, the CLI owns the daemon lifecycle.
@@ -403,7 +410,7 @@ export async function startLocalDaemon(): Promise<void> {
403
410
  }
404
411
  }
405
412
 
406
- const daemonLogFd = openHatchLogFile("daemon.log");
413
+ const daemonLogFd = openLogFile("daemon.log");
407
414
  let daemonPid: number | undefined;
408
415
  try {
409
416
  const child = spawn(daemonBinary, [], {
@@ -415,7 +422,7 @@ export async function startLocalDaemon(): Promise<void> {
415
422
  child.unref();
416
423
  daemonPid = child.pid;
417
424
  } finally {
418
- closeHatchLogFile(daemonLogFd);
425
+ closeLogFile(daemonLogFd);
419
426
  }
420
427
 
421
428
  // Write PID file immediately so the health monitor can find the process
@@ -568,7 +575,7 @@ export async function startGateway(assistantId?: string): Promise<string> {
568
575
  );
569
576
  }
570
577
 
571
- const gatewayLogFd = openHatchLogFile("gateway.log");
578
+ const gatewayLogFd = openLogFile("gateway.log");
572
579
  try {
573
580
  gateway = spawn(gatewayBinary, [], {
574
581
  detached: true,
@@ -576,12 +583,12 @@ export async function startGateway(assistantId?: string): Promise<string> {
576
583
  env: gatewayEnv,
577
584
  });
578
585
  } finally {
579
- closeHatchLogFile(gatewayLogFd);
586
+ closeLogFile(gatewayLogFd);
580
587
  }
581
588
  } else {
582
589
  // Source tree / bunx: resolve the gateway source directory and run via bun.
583
590
  const gatewayDir = resolveGatewayDir();
584
- const gwLogFd = openHatchLogFile("gateway.log");
591
+ const gwLogFd = openLogFile("gateway.log");
585
592
  try {
586
593
  gateway = spawn("bun", ["run", "src/index.ts"], {
587
594
  cwd: gatewayDir,
@@ -590,7 +597,7 @@ export async function startGateway(assistantId?: string): Promise<string> {
590
597
  env: gatewayEnv,
591
598
  });
592
599
  } finally {
593
- closeHatchLogFile(gwLogFd);
600
+ closeLogFile(gwLogFd);
594
601
  }
595
602
  }
596
603
 
@@ -0,0 +1,37 @@
1
+ import { closeSync, mkdirSync, openSync, writeSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ /** Returns the XDG-compatible log directory for Vellum CLI logs. */
6
+ export function getLogDir(): string {
7
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
8
+ return join(configHome, "vellum", "logs");
9
+ }
10
+
11
+ /** Open (or create) a log file in append mode, returning the file descriptor.
12
+ * Creates the parent directory if it doesn't exist. Returns "ignore" if the
13
+ * directory or file cannot be created (permissions, read-only filesystem, etc.)
14
+ * so that callers can fall back to discarding output instead of aborting. */
15
+ export function openLogFile(name: string): number | "ignore" {
16
+ try {
17
+ const dir = getLogDir();
18
+ mkdirSync(dir, { recursive: true });
19
+ return openSync(join(dir, name), "a");
20
+ } catch {
21
+ return "ignore";
22
+ }
23
+ }
24
+
25
+ /** Close a file descriptor returned by openLogFile (no-op for "ignore"). */
26
+ export function closeLogFile(fd: number | "ignore"): void {
27
+ if (typeof fd === "number") {
28
+ try { closeSync(fd); } catch { /* best-effort */ }
29
+ }
30
+ }
31
+
32
+ /** Write a string to a file descriptor returned by openLogFile (no-op for "ignore"). */
33
+ export function writeToLogFile(fd: number | "ignore", msg: string): void {
34
+ if (typeof fd === "number") {
35
+ try { writeSync(fd, msg); } catch { /* best-effort */ }
36
+ }
37
+ }