@vellumai/cli 0.3.22 → 0.3.23
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/commands/hatch.ts +50 -1
- package/src/components/DefaultMainScreen.tsx +4 -1
- package/src/lib/local.ts +21 -13
package/package.json
CHANGED
package/src/commands/hatch.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "crypto";
|
|
1
2
|
import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync } from "fs";
|
|
2
|
-
import { homedir, userInfo } from "os";
|
|
3
|
+
import { homedir, hostname, userInfo } from "os";
|
|
3
4
|
import { join } from "path";
|
|
4
5
|
|
|
6
|
+
import qrcode from "qrcode-terminal";
|
|
7
|
+
|
|
5
8
|
// Direct import — bun embeds this at compile time so it works in compiled binaries.
|
|
6
9
|
import cliPkg from "../../package.json";
|
|
7
10
|
|
|
@@ -478,6 +481,49 @@ function installCLISymlink(): void {
|
|
|
478
481
|
console.log(` ⚠ Could not create symlink for vellum CLI (tried ${preferredPath} and ${fallbackPath})`);
|
|
479
482
|
}
|
|
480
483
|
|
|
484
|
+
async function displayPairingQRCode(runtimeUrl: string, bearerToken: string | undefined): Promise<void> {
|
|
485
|
+
try {
|
|
486
|
+
const pairingRequestId = randomUUID();
|
|
487
|
+
const pairingSecret = randomBytes(32).toString("hex");
|
|
488
|
+
|
|
489
|
+
const registerRes = await fetch(`${runtimeUrl}/v1/pairing/register`, {
|
|
490
|
+
method: "POST",
|
|
491
|
+
headers: {
|
|
492
|
+
"Content-Type": "application/json",
|
|
493
|
+
...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
|
|
494
|
+
},
|
|
495
|
+
body: JSON.stringify({ pairingRequestId, pairingSecret, gatewayUrl: runtimeUrl }),
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
if (!registerRes.ok) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const hostId = createHash("sha256").update(hostname() + userInfo().username).digest("hex");
|
|
503
|
+
const payload = JSON.stringify({
|
|
504
|
+
type: "vellum-daemon",
|
|
505
|
+
v: 4,
|
|
506
|
+
id: hostId,
|
|
507
|
+
g: runtimeUrl,
|
|
508
|
+
pairingRequestId,
|
|
509
|
+
pairingSecret,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const qrString = await new Promise<string>((resolve) => {
|
|
513
|
+
qrcode.generate(payload, { small: true }, (code: string) => {
|
|
514
|
+
resolve(code);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
console.log("Scan this QR code with the Vellum iOS app to pair:\n");
|
|
519
|
+
console.log(qrString);
|
|
520
|
+
console.log("This pairing request expires in 5 minutes.");
|
|
521
|
+
console.log("Run `vellum pair` to generate a new one.\n");
|
|
522
|
+
} catch {
|
|
523
|
+
// Non-fatal — pairing is optional
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
481
527
|
async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false): Promise<void> {
|
|
482
528
|
const instanceName =
|
|
483
529
|
name ?? process.env.VELLUM_ASSISTANT_NAME ?? `${species}-${generateRandomSuffix()}`;
|
|
@@ -549,6 +595,9 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo
|
|
|
549
595
|
console.log(` Name: ${instanceName}`);
|
|
550
596
|
console.log(` Runtime: ${runtimeUrl}`);
|
|
551
597
|
console.log("");
|
|
598
|
+
|
|
599
|
+
// Generate and display pairing QR code
|
|
600
|
+
await displayPairingQRCode(runtimeUrl, bearerToken);
|
|
552
601
|
}
|
|
553
602
|
}
|
|
554
603
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import { randomBytes, randomUUID } from "crypto";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "crypto";
|
|
3
|
+
import { hostname, userInfo } from "os";
|
|
3
4
|
import { basename } from "path";
|
|
4
5
|
import qrcode from "qrcode-terminal";
|
|
5
6
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
|
|
@@ -1322,9 +1323,11 @@ function ChatApp({
|
|
|
1322
1323
|
throw new Error(`HTTP ${registerRes.status}: ${body || registerRes.statusText}`);
|
|
1323
1324
|
}
|
|
1324
1325
|
|
|
1326
|
+
const hostId = createHash("sha256").update(hostname() + userInfo().username).digest("hex");
|
|
1325
1327
|
const payload = JSON.stringify({
|
|
1326
1328
|
type: "vellum-daemon",
|
|
1327
1329
|
v: 4,
|
|
1330
|
+
id: hostId,
|
|
1328
1331
|
g: gatewayUrl,
|
|
1329
1332
|
pairingRequestId,
|
|
1330
1333
|
pairingSecret,
|
package/src/lib/local.ts
CHANGED
|
@@ -105,7 +105,7 @@ function resolveAssistantIndexPath(): string | undefined {
|
|
|
105
105
|
return undefined;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
async function waitForSocketFile(socketPath: string, timeoutMs =
|
|
108
|
+
async function waitForSocketFile(socketPath: string, timeoutMs = 60000): Promise<boolean> {
|
|
109
109
|
if (existsSync(socketPath)) return true;
|
|
110
110
|
|
|
111
111
|
const start = Date.now();
|
|
@@ -390,26 +390,27 @@ export async function startLocalDaemon(): Promise<void> {
|
|
|
390
390
|
}
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
-
// Wait for socket at ~/.vellum/vellum.sock (up to
|
|
394
|
-
|
|
393
|
+
// Wait for socket at ~/.vellum/vellum.sock (up to 60s — fresh installs
|
|
394
|
+
// may need 30-60s for Qdrant download, migrations, and first-time init)
|
|
395
|
+
let socketReady = await waitForSocketFile(socketFile, 60000);
|
|
395
396
|
|
|
396
397
|
// Dev fallback: if the bundled daemon did not create a socket in time,
|
|
397
398
|
// fall back to source daemon startup so local `./build.sh run` still works.
|
|
398
399
|
if (!socketReady) {
|
|
399
400
|
const assistantIndex = resolveAssistantIndexPath();
|
|
400
401
|
if (assistantIndex) {
|
|
401
|
-
console.log(" Bundled daemon socket not ready after
|
|
402
|
+
console.log(" Bundled daemon socket not ready after 60s — falling back to source daemon...");
|
|
402
403
|
// Kill the bundled daemon to avoid two processes competing for the same socket/port
|
|
403
404
|
await stopProcessByPidFile(pidFile, "bundled daemon", [socketFile]);
|
|
404
405
|
await startDaemonFromSource(assistantIndex);
|
|
405
|
-
socketReady = await waitForSocketFile(socketFile,
|
|
406
|
+
socketReady = await waitForSocketFile(socketFile, 60000);
|
|
406
407
|
}
|
|
407
408
|
}
|
|
408
409
|
|
|
409
410
|
if (socketReady) {
|
|
410
411
|
console.log(" Daemon socket ready\n");
|
|
411
412
|
} else {
|
|
412
|
-
console.log(" ⚠️ Daemon socket did not appear within
|
|
413
|
+
console.log(" ⚠️ Daemon socket did not appear within 60s — continuing anyway\n");
|
|
413
414
|
}
|
|
414
415
|
} else {
|
|
415
416
|
console.log("🔨 Starting local daemon...");
|
|
@@ -454,16 +455,16 @@ export async function startGateway(assistantId?: string): Promise<string> {
|
|
|
454
455
|
}
|
|
455
456
|
|
|
456
457
|
// If no token is available (first startup — daemon hasn't written it yet),
|
|
457
|
-
// poll for the file to appear.
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
// never reloads, so we fail rather than silently disabling auth.
|
|
458
|
+
// poll for the file to appear. On fresh installs the daemon may take 60s+
|
|
459
|
+
// for Qdrant download, migrations, and first-time init. Starting the
|
|
460
|
+
// gateway without auth is a security risk since the config is loaded once
|
|
461
|
+
// at startup and never reloads, so we fail rather than silently disabling auth.
|
|
462
462
|
if (!runtimeProxyBearerToken) {
|
|
463
463
|
console.log(" Waiting for bearer token file...");
|
|
464
|
-
const maxWait =
|
|
464
|
+
const maxWait = 60000;
|
|
465
465
|
const pollInterval = 500;
|
|
466
466
|
const start = Date.now();
|
|
467
|
+
const pidFile = join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum", "vellum.pid");
|
|
467
468
|
while (Date.now() - start < maxWait) {
|
|
468
469
|
await new Promise((r) => setTimeout(r, pollInterval));
|
|
469
470
|
try {
|
|
@@ -475,12 +476,19 @@ export async function startGateway(assistantId?: string): Promise<string> {
|
|
|
475
476
|
} catch {
|
|
476
477
|
// File still doesn't exist, keep polling.
|
|
477
478
|
}
|
|
479
|
+
// Check if the daemon process is still alive — no point waiting if it crashed
|
|
480
|
+
try {
|
|
481
|
+
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
482
|
+
if (pid) process.kill(pid, 0); // throws if process doesn't exist
|
|
483
|
+
} catch {
|
|
484
|
+
break; // daemon process is gone
|
|
485
|
+
}
|
|
478
486
|
}
|
|
479
487
|
}
|
|
480
488
|
|
|
481
489
|
if (!runtimeProxyBearerToken) {
|
|
482
490
|
throw new Error(
|
|
483
|
-
`Bearer token file not found at ${httpTokenPath} after
|
|
491
|
+
`Bearer token file not found at ${httpTokenPath} after 60s.\n` +
|
|
484
492
|
" The gateway cannot start without authentication — this would leave the proxy permanently unauthenticated.\n" +
|
|
485
493
|
" Ensure the daemon is running and has written the token file, or set VELLUM_HTTP_TOKEN_PATH to the correct path.",
|
|
486
494
|
);
|