@vellumai/cli 0.6.6 → 0.7.1

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.
Files changed (61) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.md +49 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/assistant-config.test.ts +1 -7
  5. package/src/__tests__/backup.test.ts +475 -0
  6. package/src/__tests__/config-utils.test.ts +146 -0
  7. package/src/__tests__/env-drift.test.ts +10 -32
  8. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  9. package/src/__tests__/multi-local.test.ts +0 -5
  10. package/src/__tests__/sleep.test.ts +1 -2
  11. package/src/__tests__/teleport.test.ts +988 -1266
  12. package/src/commands/backup.ts +117 -71
  13. package/src/commands/client.ts +10 -9
  14. package/src/commands/env.ts +93 -0
  15. package/src/commands/events.ts +2 -0
  16. package/src/commands/exec.ts +58 -13
  17. package/src/commands/login.ts +77 -12
  18. package/src/commands/logs.ts +2 -7
  19. package/src/commands/ps.ts +144 -25
  20. package/src/commands/restore.ts +26 -47
  21. package/src/commands/sleep.ts +5 -2
  22. package/src/commands/ssh.ts +17 -7
  23. package/src/commands/teleport.ts +462 -584
  24. package/src/commands/terminal.ts +9 -221
  25. package/src/commands/tunnel.ts +2 -7
  26. package/src/commands/upgrade.ts +108 -7
  27. package/src/commands/wake.ts +2 -1
  28. package/src/components/DefaultMainScreen.tsx +328 -154
  29. package/src/index.ts +5 -7
  30. package/src/lib/__tests__/docker.test.ts +50 -74
  31. package/src/lib/__tests__/job-polling.test.ts +278 -0
  32. package/src/lib/__tests__/local-runtime-client.test.ts +480 -0
  33. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  34. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  35. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  36. package/src/lib/assistant-client.ts +5 -21
  37. package/src/lib/assistant-config.ts +46 -24
  38. package/src/lib/cli-error.ts +1 -0
  39. package/src/lib/client-identity.ts +67 -0
  40. package/src/lib/docker.ts +75 -77
  41. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  42. package/src/lib/environments/resolve.ts +89 -7
  43. package/src/lib/environments/seeds.ts +8 -5
  44. package/src/lib/environments/types.ts +10 -0
  45. package/src/lib/hatch-local.ts +15 -120
  46. package/src/lib/health-check.ts +98 -0
  47. package/src/lib/job-polling.ts +195 -0
  48. package/src/lib/local-runtime-client.ts +231 -0
  49. package/src/lib/local.ts +165 -72
  50. package/src/lib/orphan-detection.ts +2 -35
  51. package/src/lib/platform-client.ts +190 -194
  52. package/src/lib/platform-releases.ts +23 -0
  53. package/src/lib/retire-local.ts +6 -2
  54. package/src/lib/runtime-url.ts +30 -0
  55. package/src/lib/sync-cloud-assistants.ts +126 -0
  56. package/src/lib/terminal-client.ts +6 -1
  57. package/src/lib/terminal-session.ts +536 -0
  58. package/src/lib/tui-log.ts +60 -0
  59. package/src/lib/xdg-log.ts +10 -4
  60. package/src/shared/provider-env-vars.ts +2 -3
  61. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Shared terminal session primitives for managed (cloud-hosted) assistants.
3
+ *
4
+ * Extracted from commands/terminal.ts so that ssh.ts, exec.ts, and
5
+ * terminal.ts can all use the same interactive session and assistant
6
+ * resolver without cross-importing commands (per cli/CONTRIBUTING.md).
7
+ */
8
+
9
+ import { resolveAssistant, resolveCloud } from "./assistant-config.js";
10
+ import { getPlatformUrl, readPlatformToken } from "./platform-client.js";
11
+ import {
12
+ closeTerminalSession,
13
+ createTerminalSession,
14
+ resizeTerminalSession,
15
+ sendTerminalInput,
16
+ subscribeTerminalEvents,
17
+ } from "./terminal-client.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export interface ResolvedManagedAssistant {
24
+ assistantId: string;
25
+ token: string;
26
+ platformUrl: string;
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Resolver
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Resolve a managed (cloud-hosted) assistant from the lockfile. Exits with
35
+ * an error if the assistant is not found, not managed, or the user isn't
36
+ * logged in.
37
+ */
38
+ export function resolveManagedAssistant(
39
+ nameArg?: string,
40
+ ): ResolvedManagedAssistant {
41
+ const entry = resolveAssistant(nameArg);
42
+
43
+ if (!entry) {
44
+ if (nameArg) {
45
+ console.error(`No assistant instance found with name '${nameArg}'.`);
46
+ } else {
47
+ console.error("No assistant instance found. Run `vellum hatch` first.");
48
+ }
49
+ process.exit(1);
50
+ }
51
+
52
+ const cloud = resolveCloud(entry);
53
+ if (cloud !== "vellum") {
54
+ if (cloud === "local") {
55
+ console.error(
56
+ "This assistant runs locally on your machine. You can access it directly.",
57
+ );
58
+ } else if (cloud === "docker") {
59
+ console.error(
60
+ `Use 'vellum exec -it -- /bin/bash' or 'vellum ssh' for ${cloud} instances.`,
61
+ );
62
+ } else {
63
+ console.error(
64
+ `'vellum terminal' is for managed (cloud-hosted) assistants. This assistant uses '${cloud}'.`,
65
+ );
66
+ }
67
+ process.exit(1);
68
+ }
69
+
70
+ const token = readPlatformToken();
71
+ if (!token) {
72
+ console.error(
73
+ "Not logged in. Run `vellum login` first to authenticate with the platform.",
74
+ );
75
+ process.exit(1);
76
+ }
77
+
78
+ return {
79
+ assistantId: entry.assistantId,
80
+ token,
81
+ platformUrl: getPlatformUrl(),
82
+ };
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Interactive session
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Open an interactive raw-tty terminal session to a managed assistant.
91
+ * Bridges local stdin/stdout to the platform terminal session API.
92
+ */
93
+ export async function interactiveSession(
94
+ assistant: ResolvedManagedAssistant,
95
+ initialCommand?: string,
96
+ service?: string,
97
+ ): Promise<void> {
98
+ const cols = process.stdout.columns || 80;
99
+ const rows = process.stdout.rows || 24;
100
+
101
+ console.error(`\x1b[2m🔗 Connecting to ${assistant.assistantId}...\x1b[0m`);
102
+
103
+ const { session_id: sessionId } = await createTerminalSession(
104
+ assistant.token,
105
+ assistant.assistantId,
106
+ cols,
107
+ rows,
108
+ assistant.platformUrl,
109
+ service,
110
+ );
111
+
112
+ // --- TTY raw mode setup ---
113
+ const wasRaw = process.stdin.isRaw;
114
+ if (process.stdin.isTTY) {
115
+ process.stdin.setRawMode(true);
116
+ }
117
+ process.stdin.resume();
118
+ process.stdin.setEncoding("utf-8");
119
+
120
+ // Abort controller for the SSE stream
121
+ const abortController = new AbortController();
122
+ let exiting = false;
123
+
124
+ // --- Cleanup function (idempotent) ---
125
+ async function cleanup(): Promise<void> {
126
+ if (exiting) return;
127
+ exiting = true;
128
+
129
+ // Restore tty
130
+ if (process.stdin.isTTY) {
131
+ process.stdin.setRawMode(wasRaw ?? false);
132
+ }
133
+ process.stdin.pause();
134
+
135
+ // Abort SSE stream
136
+ abortController.abort();
137
+
138
+ // Close remote session (best-effort)
139
+ try {
140
+ await closeTerminalSession(
141
+ assistant.token,
142
+ assistant.assistantId,
143
+ sessionId,
144
+ assistant.platformUrl,
145
+ );
146
+ } catch {
147
+ // Best-effort cleanup
148
+ }
149
+ }
150
+
151
+ // --- Signal handlers ---
152
+ const onSigInt = () => {
153
+ cleanup().then(() => process.exit(0));
154
+ };
155
+ const onSigTerm = () => {
156
+ cleanup().then(() => process.exit(0));
157
+ };
158
+ process.on("SIGINT", onSigInt);
159
+ process.on("SIGTERM", onSigTerm);
160
+
161
+ // --- SIGWINCH (terminal resize) ---
162
+ const onResize = () => {
163
+ const newCols = process.stdout.columns || 80;
164
+ const newRows = process.stdout.rows || 24;
165
+ resizeTerminalSession(
166
+ assistant.token,
167
+ assistant.assistantId,
168
+ sessionId,
169
+ newCols,
170
+ newRows,
171
+ assistant.platformUrl,
172
+ ).catch(() => {
173
+ // Resize failures are non-fatal
174
+ });
175
+ };
176
+ process.stdout.on("resize", onResize);
177
+
178
+ // --- Input: stdin → remote ---
179
+ let inputBuffer = "";
180
+ let inputTimer: ReturnType<typeof setTimeout> | null = null;
181
+ const INPUT_DEBOUNCE_MS = 30;
182
+
183
+ function flushInput(): void {
184
+ if (inputBuffer.length === 0) return;
185
+ const data = inputBuffer;
186
+ inputBuffer = "";
187
+ sendTerminalInput(
188
+ assistant.token,
189
+ assistant.assistantId,
190
+ sessionId,
191
+ data,
192
+ assistant.platformUrl,
193
+ ).catch((err) => {
194
+ if (!exiting) {
195
+ console.error(`\r\nInput error: ${err.message}\r\n`);
196
+ }
197
+ });
198
+ }
199
+
200
+ process.stdin.on("data", (chunk: string) => {
201
+ if (exiting) return;
202
+ inputBuffer += chunk;
203
+ if (inputTimer) clearTimeout(inputTimer);
204
+ inputTimer = setTimeout(flushInput, INPUT_DEBOUNCE_MS);
205
+ });
206
+
207
+ // --- Send initial command (for `attach` subcommand) ---
208
+ if (initialCommand) {
209
+ // Brief delay to let the shell initialize
210
+ await new Promise((resolve) => setTimeout(resolve, 300));
211
+ await sendTerminalInput(
212
+ assistant.token,
213
+ assistant.assistantId,
214
+ sessionId,
215
+ initialCommand + "\r",
216
+ assistant.platformUrl,
217
+ );
218
+ }
219
+
220
+ // --- Output: remote SSE → stdout ---
221
+ try {
222
+ for await (const event of subscribeTerminalEvents(
223
+ assistant.token,
224
+ assistant.assistantId,
225
+ sessionId,
226
+ assistant.platformUrl,
227
+ abortController.signal,
228
+ )) {
229
+ if (exiting) break;
230
+ // Decode base64 output and write raw bytes to stdout
231
+ const bytes = Buffer.from(event.data, "base64");
232
+ process.stdout.write(bytes);
233
+ }
234
+ } catch (err) {
235
+ if (!exiting) {
236
+ const msg = err instanceof Error ? err.message : String(err);
237
+ // AbortError is expected on cleanup
238
+ if (!msg.includes("abort")) {
239
+ console.error(`\r\nConnection lost: ${msg}\r\n`);
240
+ }
241
+ }
242
+ } finally {
243
+ await cleanup();
244
+
245
+ // Remove listeners
246
+ process.off("SIGINT", onSigInt);
247
+ process.off("SIGTERM", onSigTerm);
248
+ process.stdout.off("resize", onResize);
249
+ }
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Shell escape helper
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /**
257
+ * Shell-escape an array of command arguments for safe transmission to a
258
+ * remote shell. Each arg is wrapped in single quotes with internal single
259
+ * quotes escaped.
260
+ */
261
+ export function shellEscapeArgs(args: string[]): string {
262
+ return args
263
+ .map((c) => c.replace(/'/g, "'\\''"))
264
+ .map((c) => `'${c}'`)
265
+ .join(" ");
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Non-interactive exec
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /**
273
+ * Run a command non-interactively in a managed assistant service. Creates
274
+ * an ephemeral terminal session, sends the command wrapped in sentinels for
275
+ * reliable output extraction, captures the result, and exits with the
276
+ * remote command's exit code.
277
+ */
278
+ export interface NonInteractiveExecOptions {
279
+ verbose?: boolean;
280
+ /** Timeout in milliseconds. 0 disables the timeout entirely. Default: 30_000. */
281
+ timeoutMs?: number;
282
+ }
283
+
284
+ export async function nonInteractiveExec(
285
+ assistant: ResolvedManagedAssistant,
286
+ command: string[],
287
+ options?: NonInteractiveExecOptions & { service?: string },
288
+ ): Promise<void> {
289
+ const verbose = options?.verbose ?? false;
290
+ const timeoutMs = options?.timeoutMs ?? 30_000;
291
+ const dbg = verbose
292
+ ? (msg: string) => console.error(`\x1b[2m[exec] ${msg}\x1b[0m`)
293
+ : (_msg: string) => {};
294
+
295
+ dbg(`creating terminal session (cols=120, rows=24)`);
296
+
297
+ const { session_id: sessionId } = await createTerminalSession(
298
+ assistant.token,
299
+ assistant.assistantId,
300
+ 120,
301
+ 24,
302
+ assistant.platformUrl,
303
+ options?.service,
304
+ );
305
+
306
+ dbg(`session created: ${sessionId}`);
307
+
308
+ const abortController = new AbortController();
309
+ const output: Buffer[] = [];
310
+ let commandSent = false;
311
+ let eventCount = 0;
312
+ let timedOut = false;
313
+
314
+ // Unique sentinels to delimit command output
315
+ const startSentinel = `__VELLUM_EXEC_START_${Date.now()}__`;
316
+ const endSentinel = `__VELLUM_EXEC_END_${Date.now()}__`;
317
+ const exitCodeSentinel = `__VELLUM_EXIT_`;
318
+
319
+ dbg(`sentinels: start=${startSentinel} end=${endSentinel}`);
320
+
321
+ const timeout =
322
+ timeoutMs > 0
323
+ ? setTimeout(() => {
324
+ dbg(`${timeoutMs / 1000}s timeout reached — aborting`);
325
+ timedOut = true;
326
+ abortController.abort();
327
+ }, timeoutMs)
328
+ : null;
329
+
330
+ try {
331
+ for await (const event of subscribeTerminalEvents(
332
+ assistant.token,
333
+ assistant.assistantId,
334
+ sessionId,
335
+ assistant.platformUrl,
336
+ abortController.signal,
337
+ )) {
338
+ eventCount++;
339
+ const bytes = Buffer.from(event.data, "base64");
340
+ output.push(bytes);
341
+
342
+ if (verbose) {
343
+ const text = bytes.toString("utf-8");
344
+ dbg(
345
+ `SSE event #${eventCount} (seq=${event.seq}, ${bytes.length}B): ${JSON.stringify(text)}`,
346
+ );
347
+ }
348
+
349
+ // Wait for shell prompt before sending command
350
+ if (!commandSent) {
351
+ const joined = Buffer.concat(output).toString("utf-8");
352
+ if (
353
+ joined.includes("$") ||
354
+ joined.includes("#") ||
355
+ joined.includes("%")
356
+ ) {
357
+ commandSent = true;
358
+ const shellCmd = shellEscapeArgs(command);
359
+ const fullCmd = `echo '${startSentinel}'; ${shellCmd}; __ec=$?; echo '${endSentinel}'; echo '${exitCodeSentinel}'$__ec; exit $__ec\r`;
360
+ dbg(`prompt detected — sending command`);
361
+ if (verbose) {
362
+ dbg(`full command: ${JSON.stringify(fullCmd)}`);
363
+ }
364
+ // Wrap command: print start sentinel, run command, capture exit
365
+ // code, print end sentinel with exit code, then exit the shell
366
+ await sendTerminalInput(
367
+ assistant.token,
368
+ assistant.assistantId,
369
+ sessionId,
370
+ fullCmd,
371
+ assistant.platformUrl,
372
+ );
373
+ }
374
+ }
375
+
376
+ // Check for completion: require the end sentinel before looking for the
377
+ // exit code sentinel. The exit code string also appears in the command
378
+ // echo (the shell printing what was typed), so matching it alone would
379
+ // trigger a premature abort before the command even starts running.
380
+ if (commandSent) {
381
+ const accumulated = Buffer.concat(output).toString("utf-8");
382
+ // Normalize CR so CRLF line endings from the PTY don't prevent matching
383
+ const normalized = accumulated.replace(/\r/g, "");
384
+ if (
385
+ normalized.includes(endSentinel + "\n") &&
386
+ normalized.includes(exitCodeSentinel)
387
+ ) {
388
+ dbg(
389
+ `end + exit code sentinels detected — waiting 500ms for final output`,
390
+ );
391
+ // Give a moment for final output to arrive
392
+ setTimeout(() => abortController.abort(), 500);
393
+ }
394
+ }
395
+ }
396
+ } catch {
397
+ // Expected: abort on timeout or sentinel detection
398
+ } finally {
399
+ if (timeout) clearTimeout(timeout);
400
+ dbg(`stream ended after ${eventCount} events — closing session`);
401
+ await closeTerminalSession(
402
+ assistant.token,
403
+ assistant.assistantId,
404
+ sessionId,
405
+ assistant.platformUrl,
406
+ ).catch(() => {});
407
+ }
408
+
409
+ const raw = Buffer.concat(output).toString("utf-8");
410
+
411
+ if (verbose) {
412
+ dbg(`--- raw output (${raw.length} chars) ---`);
413
+ console.error(raw);
414
+ dbg(`--- end raw output ---`);
415
+ }
416
+
417
+ const clean = stripAnsi(raw);
418
+
419
+ if (verbose) {
420
+ dbg(`--- cleaned output (${clean.length} chars) ---`);
421
+ console.error(clean);
422
+ dbg(`--- end cleaned output ---`);
423
+ }
424
+
425
+ const { output: result, exitCode } = parseSentinelOutput(
426
+ clean,
427
+ startSentinel,
428
+ endSentinel,
429
+ );
430
+
431
+ dbg(`extracted result: ${result.length} chars, exit code: ${exitCode}`);
432
+
433
+ if (timedOut && !result) {
434
+ const secs = timeoutMs / 1000;
435
+ console.error(
436
+ `\x1b[31mError: command timed out after ${secs}s with no output.\x1b[0m`,
437
+ );
438
+ console.error(
439
+ `\x1b[2mTip: use --timeout <seconds> to increase the limit, or --timeout 0 to disable.\x1b[0m`,
440
+ );
441
+ process.exit(124);
442
+ }
443
+
444
+ if (timedOut && result) {
445
+ const secs = timeoutMs / 1000;
446
+ process.stdout.write(result + "\n");
447
+ console.error(
448
+ `\x1b[33mWarning: command timed out after ${secs}s (partial output above).\x1b[0m`,
449
+ );
450
+ process.exit(124);
451
+ }
452
+
453
+ if (result) {
454
+ process.stdout.write(result + "\n");
455
+ } else {
456
+ dbg(`no output extracted between sentinels`);
457
+ }
458
+
459
+ process.exit(exitCode);
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // Exported helpers — pure functions extracted for testability
464
+ // ---------------------------------------------------------------------------
465
+
466
+ const EXIT_CODE_SENTINEL = "__VELLUM_EXIT_";
467
+
468
+ /**
469
+ * Strip ANSI escape sequences and carriage returns from raw PTY output.
470
+ */
471
+ export function stripAnsi(raw: string): string {
472
+ return raw.replace(
473
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: needed for ANSI stripping
474
+ /\x1b\[[?]?[0-9;]*[a-zA-Z-~]|\x1b\][^\x07]*\x07|\x1b[()][^\n]|\r/g,
475
+ "",
476
+ );
477
+ }
478
+
479
+ export interface ParsedSentinelOutput {
480
+ output: string;
481
+ exitCode: number;
482
+ }
483
+
484
+ /**
485
+ * Extract command output and exit code from cleaned (ANSI-stripped) terminal
486
+ * output using sentinel markers.
487
+ *
488
+ * Each sentinel appears twice: once in the command echo (the shell printing
489
+ * what was typed) and once in the actual output. We find the last start
490
+ * sentinel then search forward for the first end sentinel after it.
491
+ */
492
+ export function parseSentinelOutput(
493
+ cleaned: string,
494
+ startSentinel: string,
495
+ endSentinel: string,
496
+ ): ParsedSentinelOutput {
497
+ const lines = cleaned.split("\n");
498
+
499
+ // Find the last start sentinel (the real output one, not the echo)
500
+ let startIdx = -1;
501
+ for (let i = lines.length - 1; i >= 0; i--) {
502
+ if (lines[i].includes(startSentinel)) {
503
+ startIdx = i;
504
+ break;
505
+ }
506
+ }
507
+
508
+ // Find the first end sentinel after the start sentinel
509
+ let endIdx = -1;
510
+ if (startIdx >= 0) {
511
+ for (let i = startIdx + 1; i < lines.length; i++) {
512
+ if (lines[i].includes(endSentinel)) {
513
+ endIdx = i;
514
+ break;
515
+ }
516
+ }
517
+ }
518
+
519
+ const start = startIdx >= 0 ? startIdx + 1 : 0;
520
+ const end = endIdx >= 0 ? endIdx : lines.length;
521
+ const output = lines.slice(start, end).join("\n").trim();
522
+
523
+ // Extract exit code — search backwards from the end
524
+ let exitCode = 0;
525
+ for (let i = lines.length - 1; i >= 0; i--) {
526
+ if (lines[i].includes(EXIT_CODE_SENTINEL)) {
527
+ const match = lines[i].match(/__VELLUM_EXIT_(\d+)/);
528
+ if (match) {
529
+ exitCode = parseInt(match[1], 10);
530
+ }
531
+ break;
532
+ }
533
+ }
534
+
535
+ return { output, exitCode };
536
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Structured logger for the `vellum client` TUI.
3
+ *
4
+ * Writes timestamped log lines to `<xdg-log-dir>/client-cli.log`
5
+ * (same directory used by `vellum logs` for hatch sessions). The file is
6
+ * reset on each TUI session start so it always reflects the most recent run.
7
+ *
8
+ * Usage:
9
+ * import { tuiLog } from "../lib/tui-log";
10
+ *
11
+ * tuiLog.init(); // reset + open — call once at startup
12
+ * tuiLog.info("connected", { url }); // structured write
13
+ * tuiLog.close(); // flush + close fd
14
+ *
15
+ * The log is always written — it's cheap (single file append) and invaluable
16
+ * for diagnosing SSE registration, client identity, and proxy issues.
17
+ */
18
+
19
+ import {
20
+ closeLogFile,
21
+ openLogFile,
22
+ resetLogFile,
23
+ writeToLogFile,
24
+ } from "./xdg-log.js";
25
+
26
+ const LOG_FILE = "client-cli.log";
27
+
28
+ let fd: number | "ignore" = "ignore";
29
+
30
+ function write(level: string, msg: string, extra?: Record<string, unknown>) {
31
+ const ts = new Date().toISOString();
32
+ const suffix = extra ? ` ${JSON.stringify(extra)}` : "";
33
+ writeToLogFile(fd, `${ts} [client] ${level.toUpperCase()} ${msg}${suffix}\n`);
34
+ }
35
+
36
+ export const tuiLog = {
37
+ /** Reset and open the log file. Call once at TUI startup. */
38
+ init() {
39
+ resetLogFile(LOG_FILE);
40
+ fd = openLogFile(LOG_FILE);
41
+ },
42
+
43
+ info(msg: string, extra?: Record<string, unknown>) {
44
+ write("INFO", msg, extra);
45
+ },
46
+
47
+ warn(msg: string, extra?: Record<string, unknown>) {
48
+ write("WARN", msg, extra);
49
+ },
50
+
51
+ error(msg: string, extra?: Record<string, unknown>) {
52
+ write("ERROR", msg, extra);
53
+ },
54
+
55
+ /** Close the file descriptor. Safe to call multiple times. */
56
+ close() {
57
+ closeLogFile(fd);
58
+ fd = "ignore";
59
+ },
60
+ };
@@ -9,16 +9,22 @@ import {
9
9
  writeFileSync,
10
10
  writeSync,
11
11
  } from "fs";
12
- import { homedir } from "os";
13
12
  import { join } from "path";
14
13
 
14
+ import { getConfigDir } from "./environments/paths.js";
15
+ import { getCurrentEnvironment } from "./environments/resolve.js";
16
+
15
17
  /** Regex matching pino-pretty's short time prefix, e.g. `[12:07:37.467] `. */
16
18
  const PINO_TIME_RE = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s*/;
17
19
 
18
- /** Returns the XDG-compatible log directory for Vellum CLI logs. */
20
+ /**
21
+ * Returns the XDG-compatible log directory for Vellum CLI logs.
22
+ *
23
+ * Environment-aware: production uses `$XDG_CONFIG_HOME/vellum/logs`,
24
+ * non-production environments use `$XDG_CONFIG_HOME/vellum-<env>/logs`.
25
+ */
19
26
  export function getLogDir(): string {
20
- const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
21
- return join(configHome, "vellum", "logs");
27
+ return join(getConfigDir(getCurrentEnvironment()), "logs");
22
28
  }
23
29
 
24
30
  /** Open (or create) a log file in append mode, returning the file descriptor.
@@ -3,8 +3,7 @@
3
3
  *
4
4
  * Two sources are merged into a single combined map:
5
5
  *
6
- * 1. Search-provider env vars — sourced from `meta/provider-env-vars.json`
7
- * (single source of truth, also bundled into the macOS client).
6
+ * 1. Search-provider env vars — hardcoded below (Brave, Perplexity).
8
7
  * 2. LLM-provider env vars — sourced from `PROVIDER_CATALOG` in
9
8
  * `assistant/src/providers/model-catalog.ts` via a locally-maintained
10
9
  * mirror (the CLI does not import from `assistant/src/`; drift is caught
@@ -26,7 +25,7 @@ export const LLM_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
26
25
  openrouter: "OPENROUTER_API_KEY",
27
26
  };
28
27
 
29
- /** Search-provider env var names. Mirrors `meta/provider-env-vars.json`. */
28
+ /** Search-provider env var names. */
30
29
  export const SEARCH_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
31
30
  brave: "BRAVE_API_KEY",
32
31
  perplexity: "PERPLEXITY_API_KEY",