@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 +1 -1
- package/src/adapters/install.sh +8 -3
- package/src/commands/hatch.ts +29 -0
- package/src/commands/retire.ts +36 -0
- package/src/components/DefaultMainScreen.tsx +41 -39
- package/src/lib/local.ts +40 -33
- package/src/lib/xdg-log.ts +37 -0
package/package.json
CHANGED
package/src/adapters/install.sh
CHANGED
|
@@ -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 -
|
|
39
|
-
|
|
|
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
|
|
package/src/commands/hatch.ts
CHANGED
|
@@ -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: {
|
package/src/commands/retire.ts
CHANGED
|
@@ -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
|
-
{
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
<
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
{item.
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
{
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
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
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|