@vellumai/cli 0.7.0 → 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.
- package/README.md +49 -0
- package/package.json +1 -1
- package/src/__tests__/backup.test.ts +475 -0
- package/src/__tests__/config-utils.test.ts +35 -48
- package/src/__tests__/teleport.test.ts +86 -28
- package/src/commands/backup.ts +117 -71
- package/src/commands/client.ts +10 -9
- package/src/commands/exec.ts +21 -8
- package/src/commands/hatch.ts +2 -6
- package/src/commands/login.ts +15 -33
- package/src/commands/logs.ts +2 -7
- package/src/commands/ps.ts +41 -6
- package/src/commands/restore.ts +26 -47
- package/src/commands/ssh.ts +2 -5
- package/src/commands/teleport.ts +38 -24
- package/src/commands/tunnel.ts +2 -7
- package/src/commands/upgrade.ts +108 -7
- package/src/components/DefaultMainScreen.tsx +25 -3
- package/src/index.ts +2 -7
- package/src/lib/__tests__/local-runtime-client.test.ts +122 -25
- package/src/lib/__tests__/platform-client-signed-url.test.ts +2 -2
- package/src/lib/__tests__/runtime-url.test.ts +87 -0
- package/src/lib/__tests__/terminal-session.test.ts +202 -0
- package/src/lib/assistant-client.ts +5 -21
- package/src/lib/assistant-config.ts +34 -16
- package/src/lib/cli-error.ts +1 -0
- package/src/lib/client-identity.ts +1 -1
- package/src/lib/config-utils.ts +1 -97
- package/src/lib/docker.ts +2 -2
- package/src/lib/job-polling.ts +1 -1
- package/src/lib/local-runtime-client.ts +81 -28
- package/src/lib/local.ts +27 -58
- package/src/lib/platform-client.ts +1 -220
- package/src/lib/platform-releases.ts +23 -0
- package/src/lib/runtime-url.ts +30 -0
- package/src/lib/sync-cloud-assistants.ts +126 -0
- package/src/lib/terminal-client.ts +6 -1
- package/src/lib/terminal-session.ts +127 -48
- package/src/lib/tui-log.ts +60 -0
- package/src/lib/xdg-log.ts +10 -4
|
@@ -6,11 +6,7 @@
|
|
|
6
6
|
* resolver without cross-importing commands (per cli/CONTRIBUTING.md).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
findAssistantByName,
|
|
11
|
-
loadLatestAssistant,
|
|
12
|
-
resolveCloud,
|
|
13
|
-
} from "./assistant-config.js";
|
|
9
|
+
import { resolveAssistant, resolveCloud } from "./assistant-config.js";
|
|
14
10
|
import { getPlatformUrl, readPlatformToken } from "./platform-client.js";
|
|
15
11
|
import {
|
|
16
12
|
closeTerminalSession,
|
|
@@ -42,7 +38,7 @@ export interface ResolvedManagedAssistant {
|
|
|
42
38
|
export function resolveManagedAssistant(
|
|
43
39
|
nameArg?: string,
|
|
44
40
|
): ResolvedManagedAssistant {
|
|
45
|
-
const entry =
|
|
41
|
+
const entry = resolveAssistant(nameArg);
|
|
46
42
|
|
|
47
43
|
if (!entry) {
|
|
48
44
|
if (nameArg) {
|
|
@@ -97,6 +93,7 @@ export function resolveManagedAssistant(
|
|
|
97
93
|
export async function interactiveSession(
|
|
98
94
|
assistant: ResolvedManagedAssistant,
|
|
99
95
|
initialCommand?: string,
|
|
96
|
+
service?: string,
|
|
100
97
|
): Promise<void> {
|
|
101
98
|
const cols = process.stdout.columns || 80;
|
|
102
99
|
const rows = process.stdout.rows || 24;
|
|
@@ -109,6 +106,7 @@ export async function interactiveSession(
|
|
|
109
106
|
cols,
|
|
110
107
|
rows,
|
|
111
108
|
assistant.platformUrl,
|
|
109
|
+
service,
|
|
112
110
|
);
|
|
113
111
|
|
|
114
112
|
// --- TTY raw mode setup ---
|
|
@@ -272,21 +270,24 @@ export function shellEscapeArgs(args: string[]): string {
|
|
|
272
270
|
// ---------------------------------------------------------------------------
|
|
273
271
|
|
|
274
272
|
/**
|
|
275
|
-
* Run a command non-interactively in a managed assistant
|
|
273
|
+
* Run a command non-interactively in a managed assistant service. Creates
|
|
276
274
|
* an ephemeral terminal session, sends the command wrapped in sentinels for
|
|
277
275
|
* reliable output extraction, captures the result, and exits with the
|
|
278
276
|
* remote command's exit code.
|
|
279
277
|
*/
|
|
280
278
|
export interface NonInteractiveExecOptions {
|
|
281
279
|
verbose?: boolean;
|
|
280
|
+
/** Timeout in milliseconds. 0 disables the timeout entirely. Default: 30_000. */
|
|
281
|
+
timeoutMs?: number;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
export async function nonInteractiveExec(
|
|
285
285
|
assistant: ResolvedManagedAssistant,
|
|
286
286
|
command: string[],
|
|
287
|
-
options?: NonInteractiveExecOptions,
|
|
287
|
+
options?: NonInteractiveExecOptions & { service?: string },
|
|
288
288
|
): Promise<void> {
|
|
289
289
|
const verbose = options?.verbose ?? false;
|
|
290
|
+
const timeoutMs = options?.timeoutMs ?? 30_000;
|
|
290
291
|
const dbg = verbose
|
|
291
292
|
? (msg: string) => console.error(`\x1b[2m[exec] ${msg}\x1b[0m`)
|
|
292
293
|
: (_msg: string) => {};
|
|
@@ -299,6 +300,7 @@ export async function nonInteractiveExec(
|
|
|
299
300
|
120,
|
|
300
301
|
24,
|
|
301
302
|
assistant.platformUrl,
|
|
303
|
+
options?.service,
|
|
302
304
|
);
|
|
303
305
|
|
|
304
306
|
dbg(`session created: ${sessionId}`);
|
|
@@ -307,6 +309,7 @@ export async function nonInteractiveExec(
|
|
|
307
309
|
const output: Buffer[] = [];
|
|
308
310
|
let commandSent = false;
|
|
309
311
|
let eventCount = 0;
|
|
312
|
+
let timedOut = false;
|
|
310
313
|
|
|
311
314
|
// Unique sentinels to delimit command output
|
|
312
315
|
const startSentinel = `__VELLUM_EXEC_START_${Date.now()}__`;
|
|
@@ -315,10 +318,14 @@ export async function nonInteractiveExec(
|
|
|
315
318
|
|
|
316
319
|
dbg(`sentinels: start=${startSentinel} end=${endSentinel}`);
|
|
317
320
|
|
|
318
|
-
const timeout =
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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;
|
|
322
329
|
|
|
323
330
|
try {
|
|
324
331
|
for await (const event of subscribeTerminalEvents(
|
|
@@ -334,7 +341,9 @@ export async function nonInteractiveExec(
|
|
|
334
341
|
|
|
335
342
|
if (verbose) {
|
|
336
343
|
const text = bytes.toString("utf-8");
|
|
337
|
-
dbg(
|
|
344
|
+
dbg(
|
|
345
|
+
`SSE event #${eventCount} (seq=${event.seq}, ${bytes.length}B): ${JSON.stringify(text)}`,
|
|
346
|
+
);
|
|
338
347
|
}
|
|
339
348
|
|
|
340
349
|
// Wait for shell prompt before sending command
|
|
@@ -364,11 +373,21 @@ export async function nonInteractiveExec(
|
|
|
364
373
|
}
|
|
365
374
|
}
|
|
366
375
|
|
|
367
|
-
// Check for end sentinel
|
|
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.
|
|
368
380
|
if (commandSent) {
|
|
369
381
|
const accumulated = Buffer.concat(output).toString("utf-8");
|
|
370
|
-
|
|
371
|
-
|
|
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
|
+
);
|
|
372
391
|
// Give a moment for final output to arrive
|
|
373
392
|
setTimeout(() => abortController.abort(), 500);
|
|
374
393
|
}
|
|
@@ -377,7 +396,7 @@ export async function nonInteractiveExec(
|
|
|
377
396
|
} catch {
|
|
378
397
|
// Expected: abort on timeout or sentinel detection
|
|
379
398
|
} finally {
|
|
380
|
-
clearTimeout(timeout);
|
|
399
|
+
if (timeout) clearTimeout(timeout);
|
|
381
400
|
dbg(`stream ended after ${eventCount} events — closing session`);
|
|
382
401
|
await closeTerminalSession(
|
|
383
402
|
assistant.token,
|
|
@@ -387,7 +406,6 @@ export async function nonInteractiveExec(
|
|
|
387
406
|
).catch(() => {});
|
|
388
407
|
}
|
|
389
408
|
|
|
390
|
-
// Parse output between sentinels
|
|
391
409
|
const raw = Buffer.concat(output).toString("utf-8");
|
|
392
410
|
|
|
393
411
|
if (verbose) {
|
|
@@ -396,12 +414,7 @@ export async function nonInteractiveExec(
|
|
|
396
414
|
dbg(`--- end raw output ---`);
|
|
397
415
|
}
|
|
398
416
|
|
|
399
|
-
|
|
400
|
-
const clean = raw.replace(
|
|
401
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: needed for ANSI stripping
|
|
402
|
-
/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][^\n]|\r/g,
|
|
403
|
-
"",
|
|
404
|
-
);
|
|
417
|
+
const clean = stripAnsi(raw);
|
|
405
418
|
|
|
406
419
|
if (verbose) {
|
|
407
420
|
dbg(`--- cleaned output (${clean.length} chars) ---`);
|
|
@@ -409,40 +422,108 @@ export async function nonInteractiveExec(
|
|
|
409
422
|
dbg(`--- end cleaned output ---`);
|
|
410
423
|
}
|
|
411
424
|
|
|
412
|
-
const
|
|
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
|
+
}
|
|
413
483
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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)
|
|
417
500
|
let startIdx = -1;
|
|
418
|
-
let endIdx = -1;
|
|
419
501
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
420
|
-
if (
|
|
421
|
-
endIdx = i;
|
|
422
|
-
}
|
|
423
|
-
if (startIdx < 0 && lines[i].includes(startSentinel)) {
|
|
502
|
+
if (lines[i].includes(startSentinel)) {
|
|
424
503
|
startIdx = i;
|
|
504
|
+
break;
|
|
425
505
|
}
|
|
426
506
|
}
|
|
427
507
|
|
|
428
|
-
|
|
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
|
+
}
|
|
429
518
|
|
|
430
519
|
const start = startIdx >= 0 ? startIdx + 1 : 0;
|
|
431
520
|
const end = endIdx >= 0 ? endIdx : lines.length;
|
|
432
|
-
const
|
|
521
|
+
const output = lines.slice(start, end).join("\n").trim();
|
|
433
522
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
if (result) {
|
|
437
|
-
process.stdout.write(result + "\n");
|
|
438
|
-
} else {
|
|
439
|
-
dbg(`no output extracted between sentinels`);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Extract exit code from sentinel (also search backwards)
|
|
523
|
+
// Extract exit code — search backwards from the end
|
|
443
524
|
let exitCode = 0;
|
|
444
525
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
445
|
-
if (lines[i].includes(
|
|
526
|
+
if (lines[i].includes(EXIT_CODE_SENTINEL)) {
|
|
446
527
|
const match = lines[i].match(/__VELLUM_EXIT_(\d+)/);
|
|
447
528
|
if (match) {
|
|
448
529
|
exitCode = parseInt(match[1], 10);
|
|
@@ -451,7 +532,5 @@ export async function nonInteractiveExec(
|
|
|
451
532
|
}
|
|
452
533
|
}
|
|
453
534
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
process.exit(exitCode);
|
|
535
|
+
return { output, exitCode };
|
|
457
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
|
+
};
|
package/src/lib/xdg-log.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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.
|