@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 +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/constants.ts +13 -13
- package/src/lib/local.ts +67 -57
- 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/constants.ts
CHANGED
|
@@ -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}
|
|
55
|
-
`${ANSI.magenta}
|
|
56
|
-
`${ANSI.magenta}
|
|
57
|
-
`${ANSI.magenta}
|
|
58
|
-
`${ANSI.magenta}
|
|
59
|
-
`${ANSI.magenta}
|
|
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
|
|
63
|
+
"Warming up the mothership...",
|
|
64
64
|
"Getting cozy in there...",
|
|
65
|
-
"
|
|
66
|
-
"
|
|
65
|
+
"Calibrating the antenna...",
|
|
66
|
+
"Scanning the galaxy...",
|
|
67
67
|
],
|
|
68
68
|
runningMessages: [
|
|
69
69
|
"Running startup script...",
|
|
70
|
-
"Teaching the
|
|
71
|
-
"
|
|
72
|
-
"Almost ready to
|
|
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 {
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 (
|
|
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
|
-
}
|
|
307
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|