@vellumai/cli 0.4.7 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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
 
@@ -51,25 +51,25 @@ export const SPECIES_CONFIG: Record<Species, SpeciesConfig> = {
51
51
  vellum: {
52
52
  color: ANSI.magenta,
53
53
  art: [
54
- `${ANSI.magenta} ,___,${ANSI.reset}`,
55
- `${ANSI.magenta} (${ANSI.reset}${ANSI.bold} O O ${ANSI.reset}${ANSI.magenta})${ANSI.reset}`,
56
- `${ANSI.magenta} /)${ANSI.reset}${ANSI.bold}V${ANSI.reset}${ANSI.magenta}(\\${ANSI.reset}`,
57
- `${ANSI.magenta} // \\\\${ANSI.reset}`,
58
- `${ANSI.magenta} /" "\\${ANSI.reset}`,
59
- `${ANSI.magenta} ^ ^${ANSI.reset}`,
54
+ `${ANSI.magenta} .-.-.-.${ANSI.reset}`,
55
+ `${ANSI.magenta} |${ANSI.reset}${ANSI.bold} o o ${ANSI.reset}${ANSI.magenta}|${ANSI.reset}`,
56
+ `${ANSI.magenta} |${ANSI.reset}${ANSI.bold} --- ${ANSI.reset}${ANSI.magenta}|${ANSI.reset}`,
57
+ `${ANSI.magenta} |_|_|_|_|${ANSI.reset}`,
58
+ `${ANSI.magenta} | | | |${ANSI.reset}`,
59
+ `${ANSI.magenta} ^ ^_^ ^${ANSI.reset}`,
60
60
  ],
61
- hatchedEmoji: "🦉",
61
+ hatchedEmoji: "👾",
62
62
  waitingMessages: [
63
- "Warming up the nest...",
63
+ "Warming up the mothership...",
64
64
  "Getting cozy in there...",
65
- "Fluffing the feathers...",
66
- "Preening in the moonlight...",
65
+ "Calibrating the antenna...",
66
+ "Scanning the galaxy...",
67
67
  ],
68
68
  runningMessages: [
69
69
  "Running startup script...",
70
- "Teaching the owlet to code...",
71
- "Spreading wings...",
72
- "Almost ready to take flight...",
70
+ "Teaching the alien to code...",
71
+ "Powering up...",
72
+ "Almost ready to beam down...",
73
73
  ],
74
74
  },
75
75
  };
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");
@@ -276,41 +251,76 @@ function isSocketResponsive(socketPath: string, timeoutMs = 1500): Promise<boole
276
251
 
277
252
  async function discoverPublicUrl(): Promise<string | undefined> {
278
253
  const cloud = process.env.VELLUM_CLOUD;
279
- if (!cloud || cloud === "local") {
280
- return `http://localhost:${GATEWAY_PORT}`;
281
- }
282
254
 
283
255
  let externalIp: string | undefined;
284
- try {
285
- if (cloud === "gcp") {
286
- const resp = await fetch(
287
- "http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
288
- { headers: { "Metadata-Flavor": "Google" } },
289
- );
290
- if (resp.ok) externalIp = (await resp.text()).trim();
291
- } else if (cloud === "aws") {
292
- // Use IMDSv2 (token-based) for compatibility with HttpTokens=required
293
- const tokenResp = await fetch(
294
- "http://169.254.169.254/latest/api/token",
295
- { method: "PUT", headers: { "X-aws-ec2-metadata-token-ttl-seconds": "30" } },
296
- );
297
- if (tokenResp.ok) {
298
- const token = await tokenResp.text();
299
- const ipResp = await fetch(
300
- "http://169.254.169.254/latest/meta-data/public-ipv4",
301
- { headers: { "X-aws-ec2-metadata-token": token } },
256
+
257
+ // Try cloud-specific metadata services first for GCP and AWS.
258
+ if (cloud && cloud !== "local") {
259
+ try {
260
+ if (cloud === "gcp") {
261
+ const resp = await fetch(
262
+ "http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
263
+ { headers: { "Metadata-Flavor": "Google" } },
264
+ );
265
+ if (resp.ok) externalIp = (await resp.text()).trim();
266
+ } else if (cloud === "aws") {
267
+ // Use IMDSv2 (token-based) for compatibility with HttpTokens=required
268
+ const tokenResp = await fetch(
269
+ "http://169.254.169.254/latest/api/token",
270
+ { method: "PUT", headers: { "X-aws-ec2-metadata-token-ttl-seconds": "30" } },
302
271
  );
303
- if (ipResp.ok) externalIp = (await ipResp.text()).trim();
272
+ if (tokenResp.ok) {
273
+ const token = await tokenResp.text();
274
+ const ipResp = await fetch(
275
+ "http://169.254.169.254/latest/meta-data/public-ipv4",
276
+ { headers: { "X-aws-ec2-metadata-token": token } },
277
+ );
278
+ if (ipResp.ok) externalIp = (await ipResp.text()).trim();
279
+ }
304
280
  }
281
+ } catch {
282
+ // metadata service not reachable
305
283
  }
306
- } catch {
307
- // metadata service not reachable
284
+ }
285
+
286
+ // Fall back to a public IP discovery service for all environments
287
+ // (local, custom, or when cloud-specific metadata didn't resolve).
288
+ if (!externalIp) {
289
+ externalIp = await discoverPublicIpFallback();
308
290
  }
309
291
 
310
292
  if (externalIp) {
311
293
  console.log(` Discovered external IP: ${externalIp}`);
312
294
  return `http://${externalIp}:${GATEWAY_PORT}`;
313
295
  }
296
+
297
+ // Final fallback to localhost when no public IP could be discovered.
298
+ return `http://localhost:${GATEWAY_PORT}`;
299
+ }
300
+
301
+ /** Try to discover the machine's public IP using external services.
302
+ * Attempts multiple providers for resilience. */
303
+ async function discoverPublicIpFallback(): Promise<string | undefined> {
304
+ const services = [
305
+ "https://api.ipify.org",
306
+ "https://ifconfig.me/ip",
307
+ "https://icanhazip.com",
308
+ ];
309
+
310
+ for (const url of services) {
311
+ try {
312
+ const resp = await fetch(url, { signal: AbortSignal.timeout(3000) });
313
+ if (resp.ok) {
314
+ const ip = (await resp.text()).trim();
315
+ // Basic validation: must look like an IPv4 or IPv6 address
316
+ if (ip && /^[\d.:a-fA-F]+$/.test(ip)) {
317
+ return ip;
318
+ }
319
+ }
320
+ } catch {
321
+ // Service unreachable, try the next one
322
+ }
323
+ }
314
324
  return undefined;
315
325
  }
316
326
 
@@ -403,7 +413,7 @@ export async function startLocalDaemon(): Promise<void> {
403
413
  }
404
414
  }
405
415
 
406
- const daemonLogFd = openHatchLogFile("daemon.log");
416
+ const daemonLogFd = openLogFile("daemon.log");
407
417
  let daemonPid: number | undefined;
408
418
  try {
409
419
  const child = spawn(daemonBinary, [], {
@@ -415,7 +425,7 @@ export async function startLocalDaemon(): Promise<void> {
415
425
  child.unref();
416
426
  daemonPid = child.pid;
417
427
  } finally {
418
- closeHatchLogFile(daemonLogFd);
428
+ closeLogFile(daemonLogFd);
419
429
  }
420
430
 
421
431
  // Write PID file immediately so the health monitor can find the process
@@ -568,7 +578,7 @@ export async function startGateway(assistantId?: string): Promise<string> {
568
578
  );
569
579
  }
570
580
 
571
- const gatewayLogFd = openHatchLogFile("gateway.log");
581
+ const gatewayLogFd = openLogFile("gateway.log");
572
582
  try {
573
583
  gateway = spawn(gatewayBinary, [], {
574
584
  detached: true,
@@ -576,12 +586,12 @@ export async function startGateway(assistantId?: string): Promise<string> {
576
586
  env: gatewayEnv,
577
587
  });
578
588
  } finally {
579
- closeHatchLogFile(gatewayLogFd);
589
+ closeLogFile(gatewayLogFd);
580
590
  }
581
591
  } else {
582
592
  // Source tree / bunx: resolve the gateway source directory and run via bun.
583
593
  const gatewayDir = resolveGatewayDir();
584
- const gwLogFd = openHatchLogFile("gateway.log");
594
+ const gwLogFd = openLogFile("gateway.log");
585
595
  try {
586
596
  gateway = spawn("bun", ["run", "src/index.ts"], {
587
597
  cwd: gatewayDir,
@@ -590,7 +600,7 @@ export async function startGateway(assistantId?: string): Promise<string> {
590
600
  env: gatewayEnv,
591
601
  });
592
602
  } finally {
593
- closeHatchLogFile(gwLogFd);
603
+ closeLogFile(gwLogFd);
594
604
  }
595
605
  }
596
606
 
@@ -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
+ }