@vellumai/cli 0.7.0 → 0.7.2

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 (54) hide show
  1. package/AGENTS.md +3 -11
  2. package/README.md +49 -0
  3. package/bun.lock +0 -15
  4. package/package.json +1 -6
  5. package/src/__tests__/backup.test.ts +591 -0
  6. package/src/__tests__/config-utils.test.ts +35 -48
  7. package/src/__tests__/teleport.test.ts +597 -37
  8. package/src/commands/backup.ts +149 -70
  9. package/src/commands/client.ts +56 -14
  10. package/src/commands/events.ts +3 -0
  11. package/src/commands/exec.ts +34 -12
  12. package/src/commands/hatch.ts +3 -7
  13. package/src/commands/login.ts +15 -33
  14. package/src/commands/logs.ts +2 -7
  15. package/src/commands/ps.ts +41 -6
  16. package/src/commands/restore.ts +32 -47
  17. package/src/commands/setup.ts +38 -73
  18. package/src/commands/ssh.ts +2 -5
  19. package/src/commands/teleport.ts +148 -34
  20. package/src/commands/tunnel.ts +2 -7
  21. package/src/commands/upgrade.ts +114 -7
  22. package/src/commands/wake.ts +5 -16
  23. package/src/components/DefaultMainScreen.tsx +65 -129
  24. package/src/index.ts +2 -13
  25. package/src/lib/__tests__/docker.test.ts +50 -32
  26. package/src/lib/__tests__/local-runtime-client.test.ts +308 -25
  27. package/src/lib/__tests__/platform-client-signed-url.test.ts +237 -2
  28. package/src/lib/__tests__/runtime-url.test.ts +125 -0
  29. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  30. package/src/lib/assistant-client.ts +18 -26
  31. package/src/lib/assistant-config.ts +34 -41
  32. package/src/lib/backup-ops.ts +43 -17
  33. package/src/lib/cli-error.ts +1 -0
  34. package/src/lib/client-identity.ts +1 -1
  35. package/src/lib/config-utils.ts +1 -97
  36. package/src/lib/docker-statefulset.ts +381 -0
  37. package/src/lib/docker.ts +8 -247
  38. package/src/lib/guardian-token.ts +56 -6
  39. package/src/lib/hatch-local.ts +3 -26
  40. package/src/lib/job-polling.ts +1 -1
  41. package/src/lib/local-runtime-client.ts +162 -28
  42. package/src/lib/local.ts +35 -64
  43. package/src/lib/ngrok.ts +36 -26
  44. package/src/lib/platform-client.ts +97 -221
  45. package/src/lib/platform-releases.ts +23 -0
  46. package/src/lib/retire-local.ts +2 -2
  47. package/src/lib/runtime-url.ts +52 -0
  48. package/src/lib/sync-cloud-assistants.ts +126 -0
  49. package/src/lib/terminal-client.ts +6 -1
  50. package/src/lib/terminal-session.ts +127 -48
  51. package/src/lib/tui-log.ts +60 -0
  52. package/src/lib/upgrade-lifecycle.ts +65 -0
  53. package/src/lib/xdg-log.ts +10 -4
  54. package/src/commands/pair.ts +0 -212
@@ -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 = nameArg ? findAssistantByName(nameArg) : loadLatestAssistant();
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 container. Creates
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 = setTimeout(() => {
319
- dbg(`30s timeout reached — aborting`);
320
- abortController.abort();
321
- }, 30_000);
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(`SSE event #${eventCount} (seq=${event.seq}, ${bytes.length}B): ${JSON.stringify(text)}`);
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 in accumulated output
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
- if (accumulated.includes(exitCodeSentinel)) {
371
- dbg(`exit code sentinel detected — waiting 500ms for final output`);
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
- // Strip ANSI escapes
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 lines = clean.split("\n");
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
- // Find output between sentinels. Search backwards because each sentinel
415
- // string appears twice: once in the shell command echo and once in the
416
- // actual output. We want the last occurrence (the output line).
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 (endIdx < 0 && lines[i].includes(endSentinel)) {
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
- dbg(`sentinel indices: startLine=${startIdx} endLine=${endIdx} (of ${lines.length} lines)`);
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 result = lines.slice(start, end).join("\n").trim();
521
+ const output = lines.slice(start, end).join("\n").trim();
433
522
 
434
- dbg(`extracted result: ${result.length} chars`);
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(exitCodeSentinel)) {
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
- dbg(`exit code: ${exitCode}`);
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
+ };
@@ -1,4 +1,8 @@
1
1
  import { randomBytes } from "crypto";
2
+ import { spawnSync } from "child_process";
3
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { join } from "path";
2
6
 
3
7
  import type { AssistantEntry } from "./assistant-config.js";
4
8
  import { saveAssistantEntry } from "./assistant-config.js";
@@ -18,6 +22,62 @@ import { resolveImageRefs } from "./platform-releases.js";
18
22
  import { exec, execOutput } from "./step-runner.js";
19
23
  import { compareVersions } from "./version-compat.js";
20
24
 
25
+ // ---------------------------------------------------------------------------
26
+ // Failure log capture
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** XDG-compliant directory for upgrade failure logs */
30
+ function getUpgradeLogsDir(): string {
31
+ const stateHome =
32
+ process.env.XDG_STATE_HOME?.trim() || join(homedir(), ".local", "state");
33
+ return join(stateHome, "vellum", "upgrade-logs");
34
+ }
35
+
36
+ /**
37
+ * Capture stdout/stderr from all three containers after a readiness failure
38
+ * and write them to an XDG state directory. Returns the directory path so
39
+ * the caller can print it for the user.
40
+ *
41
+ * Runs best-effort — never throws.
42
+ */
43
+ export async function captureUpgradeFailureLogs(
44
+ res: ReturnType<typeof dockerResourceNames>,
45
+ label: string,
46
+ ): Promise<string | null> {
47
+ const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
48
+ const logDir = join(getUpgradeLogsDir(), `${label}-${isoTimestamp}`);
49
+ try {
50
+ mkdirSync(logDir, { recursive: true });
51
+
52
+ const containers: [string, string][] = [
53
+ [res.assistantContainer, "assistant.log"],
54
+ [res.gatewayContainer, "gateway.log"],
55
+ [res.cesContainer, "credential-executor.log"],
56
+ ];
57
+
58
+ for (const [container, filename] of containers) {
59
+ try {
60
+ // Capture stdout + stderr together so container logs written to either
61
+ // stream (docker logs writes container stdout→stdout, stderr→stderr)
62
+ // are preserved in a single file. spawnSync avoids the execOutput
63
+ // limitation of returning only stdout on success.
64
+ const result = spawnSync("docker", ["logs", "--tail", "500", container], {
65
+ encoding: "utf8",
66
+ maxBuffer: 10 * 1024 * 1024, // 10 MB
67
+ });
68
+ const output = [result.stdout, result.stderr].filter(Boolean).join("");
69
+ if (output) writeFileSync(join(logDir, filename), output);
70
+ } catch {
71
+ // Container may not exist or may have already been removed
72
+ }
73
+ }
74
+
75
+ return existsSync(logDir) ? logDir : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
21
81
  // ---------------------------------------------------------------------------
22
82
  // Shared constants & builders for upgrade / rollback lifecycle events
23
83
  // ---------------------------------------------------------------------------
@@ -734,6 +794,11 @@ export async function performDockerRollback(
734
794
  // Failure path — attempt auto-rollback to original version
735
795
  console.error(`\n❌ Containers failed to become ready within the timeout.`);
736
796
 
797
+ const logDir = await captureUpgradeFailureLogs(res, `${instanceName}-rollback-failure`);
798
+ if (logDir) {
799
+ console.log(`📋 Container logs saved to: ${logDir}`);
800
+ }
801
+
737
802
  if (currentImageRefs) {
738
803
  await broadcastUpgradeEvent(
739
804
  entry.runtimeUrl,
@@ -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.
@@ -1,212 +0,0 @@
1
- import { createHash } from "crypto";
2
- import { readFileSync } from "fs";
3
- import jsQR from "jsqr";
4
- import { hostname, userInfo } from "os";
5
- import { PNG } from "pngjs";
6
-
7
- import { saveAssistantEntry } from "../lib/assistant-config";
8
- import type { AssistantEntry } from "../lib/assistant-config";
9
- import type { Species } from "../lib/constants";
10
- import { saveGuardianToken } from "../lib/guardian-token";
11
- import type { GuardianTokenData } from "../lib/guardian-token";
12
- import { generateInstanceName } from "../lib/random-name";
13
-
14
- interface QRPairingPayload {
15
- type: string;
16
- v: number;
17
- id?: string;
18
- g: string;
19
- pairingRequestId: string;
20
- pairingSecret: string;
21
- }
22
-
23
- interface PairingResponse {
24
- status: "approved" | "pending";
25
- bearerToken?: string;
26
- gatewayUrl?: string;
27
- }
28
-
29
- function decodeQRCodeFromPng(pngPath: string): string {
30
- const fileData = readFileSync(pngPath);
31
- const png = PNG.sync.read(fileData);
32
- const code = jsQR(new Uint8ClampedArray(png.data), png.width, png.height);
33
- if (!code) {
34
- throw new Error("Could not decode QR code from the provided PNG image.");
35
- }
36
- return code.data;
37
- }
38
-
39
- function safeUserInfoUsername(): string {
40
- try {
41
- return userInfo().username;
42
- } catch {
43
- return "";
44
- }
45
- }
46
-
47
- function getDeviceId(): string {
48
- const raw = hostname() + safeUserInfoUsername();
49
- return createHash("sha256").update(raw).digest("hex");
50
- }
51
-
52
- const PAIRING_POLL_INTERVAL_MS = 2000;
53
- const PAIRING_POLL_TIMEOUT_MS = 120_000;
54
-
55
- async function pollForApproval(
56
- gatewayUrl: string,
57
- pairingRequestId: string,
58
- pairingSecret: string,
59
- ): Promise<PairingResponse> {
60
- const startTime = Date.now();
61
-
62
- while (Date.now() - startTime < PAIRING_POLL_TIMEOUT_MS) {
63
- const statusUrl = `${gatewayUrl}/pairing/status?id=${encodeURIComponent(pairingRequestId)}&secret=${encodeURIComponent(pairingSecret)}`;
64
- const statusRes = await fetch(statusUrl);
65
-
66
- if (!statusRes.ok) {
67
- const body = await statusRes.text().catch(() => "");
68
- throw new Error(
69
- `Failed to check pairing status: HTTP ${statusRes.status}: ${body || statusRes.statusText}`,
70
- );
71
- }
72
-
73
- const statusBody = (await statusRes.json()) as PairingResponse;
74
-
75
- if (statusBody.status === "approved") {
76
- return statusBody;
77
- }
78
-
79
- await new Promise((resolve) =>
80
- setTimeout(resolve, PAIRING_POLL_INTERVAL_MS),
81
- );
82
- }
83
-
84
- throw new Error("Pairing timed out waiting for approval.");
85
- }
86
-
87
- export async function pair(): Promise<void> {
88
- const args = process.argv.slice(3);
89
-
90
- if (args.includes("--help") || args.includes("-h")) {
91
- console.log("Usage: vellum pair <path-to-qrcode.png>");
92
- console.log("");
93
- console.log(
94
- "Pair with a remote assistant by scanning the QR code PNG generated during setup.",
95
- );
96
- process.exit(0);
97
- }
98
-
99
- const qrCodePath = args[0] || process.env.VELLUM_CUSTOM_QR_CODE_PATH;
100
-
101
- if (!qrCodePath) {
102
- console.error("Usage: vellum pair <path-to-qrcode.png>");
103
- console.error("");
104
- console.error(
105
- "Pair with a remote assistant by scanning the QR code PNG generated during setup.",
106
- );
107
- process.exit(1);
108
- }
109
-
110
- const species: Species = "vellum";
111
-
112
- try {
113
- console.log("Reading QR code from provided image...");
114
- const qrData = decodeQRCodeFromPng(qrCodePath);
115
-
116
- let payload: QRPairingPayload;
117
- try {
118
- payload = JSON.parse(qrData) as QRPairingPayload;
119
- } catch {
120
- throw new Error("QR code does not contain valid pairing data.");
121
- }
122
-
123
- if (
124
- payload.type !== "vellum-daemon" ||
125
- !payload.g ||
126
- !payload.pairingRequestId ||
127
- !payload.pairingSecret
128
- ) {
129
- throw new Error("QR code does not contain valid Vellum pairing data.");
130
- }
131
-
132
- const instanceName = generateInstanceName(species);
133
- const runtimeUrl = payload.g;
134
- const deviceId = getDeviceId();
135
- const deviceName = hostname();
136
-
137
- console.log(`Pairing with remote assistant at ${runtimeUrl}...`);
138
-
139
- const requestUrl = `${runtimeUrl}/pairing/request`;
140
- const requestRes = await fetch(requestUrl, {
141
- method: "POST",
142
- headers: { "Content-Type": "application/json" },
143
- body: JSON.stringify({
144
- pairingRequestId: payload.pairingRequestId,
145
- pairingSecret: payload.pairingSecret,
146
- deviceId,
147
- deviceName,
148
- }),
149
- });
150
-
151
- if (!requestRes.ok) {
152
- const body = await requestRes.text().catch(() => "");
153
- throw new Error(
154
- `Failed to initiate pairing: HTTP ${requestRes.status}: ${body || requestRes.statusText}`,
155
- );
156
- }
157
-
158
- const requestBody = (await requestRes.json()) as PairingResponse;
159
-
160
- let bearerToken: string | undefined;
161
-
162
- if (requestBody.status === "approved") {
163
- bearerToken = requestBody.bearerToken;
164
- } else if (requestBody.status === "pending") {
165
- console.log("Waiting for pairing approval...");
166
- const approvedResponse = await pollForApproval(
167
- runtimeUrl,
168
- payload.pairingRequestId,
169
- payload.pairingSecret,
170
- );
171
- bearerToken = approvedResponse.bearerToken;
172
- } else {
173
- throw new Error(
174
- `Unexpected pairing response status: ${requestBody.status}`,
175
- );
176
- }
177
-
178
- const customEntry: AssistantEntry = {
179
- assistantId: instanceName,
180
- runtimeUrl,
181
- cloud: "custom",
182
- species,
183
- hatchedAt: new Date().toISOString(),
184
- };
185
- saveAssistantEntry(customEntry);
186
-
187
- if (bearerToken) {
188
- const tokenData: GuardianTokenData = {
189
- guardianPrincipalId: "",
190
- accessToken: bearerToken,
191
- accessTokenExpiresAt: "",
192
- refreshToken: "",
193
- refreshTokenExpiresAt: "",
194
- refreshAfter: "",
195
- isNew: true,
196
- deviceId: getDeviceId(),
197
- leasedAt: new Date().toISOString(),
198
- };
199
- saveGuardianToken(instanceName, tokenData);
200
- }
201
-
202
- console.log("");
203
- console.log("Successfully paired with remote assistant!");
204
- console.log("Instance details:");
205
- console.log(` Name: ${instanceName}`);
206
- console.log(` Runtime URL: ${runtimeUrl}`);
207
- console.log("");
208
- } catch (error) {
209
- console.error("Error:", error instanceof Error ? error.message : error);
210
- process.exit(1);
211
- }
212
- }