@vellumai/cli 0.1.9 → 0.1.11
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 +20 -2
- package/src/commands/hatch.ts +12 -640
- package/src/commands/ps.ts +322 -0
- package/src/commands/retire.ts +9 -41
- package/src/commands/sleep.ts +25 -53
- package/src/commands/wake.ts +52 -0
- package/src/index.ts +6 -0
- package/src/lib/assistant-config.ts +4 -0
- package/src/lib/aws.ts +18 -0
- package/src/lib/gcp.ts +430 -3
- package/src/lib/local.ts +323 -0
- package/src/lib/process.ts +93 -0
package/src/commands/hatch.ts
CHANGED
|
@@ -1,35 +1,31 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
2
1
|
import { randomBytes } from "crypto";
|
|
3
|
-
import { existsSync,
|
|
2
|
+
import { existsSync, unlinkSync, writeFileSync } from "fs";
|
|
4
3
|
import { createRequire } from "module";
|
|
5
|
-
import { tmpdir, userInfo
|
|
6
|
-
import {
|
|
4
|
+
import { tmpdir, userInfo } from "os";
|
|
5
|
+
import { join } from "path";
|
|
7
6
|
|
|
8
7
|
import { buildOpenclawStartupScript } from "../adapters/openclaw";
|
|
9
8
|
import { saveAssistantEntry } from "../lib/assistant-config";
|
|
10
9
|
import type { AssistantEntry } from "../lib/assistant-config";
|
|
11
10
|
import { hatchAws } from "../lib/aws";
|
|
12
11
|
import {
|
|
13
|
-
FIREWALL_TAG,
|
|
14
12
|
GATEWAY_PORT,
|
|
15
13
|
SPECIES_CONFIG,
|
|
16
14
|
VALID_REMOTE_HOSTS,
|
|
17
15
|
VALID_SPECIES,
|
|
18
16
|
} from "../lib/constants";
|
|
19
17
|
import type { RemoteHost, Species } from "../lib/constants";
|
|
20
|
-
import
|
|
21
|
-
import {
|
|
18
|
+
import { hatchGcp } from "../lib/gcp";
|
|
19
|
+
import type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
22
20
|
import { buildInterfacesSeed } from "../lib/interfaces-seed";
|
|
21
|
+
import { startLocalDaemon, startGateway } from "../lib/local";
|
|
23
22
|
import { generateRandomSuffix } from "../lib/random-name";
|
|
24
|
-
import { exec
|
|
23
|
+
import { exec } from "../lib/step-runner";
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
export type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
27
26
|
|
|
28
27
|
const INSTALL_SCRIPT_REMOTE_PATH = "/tmp/vellum-install.sh";
|
|
29
|
-
const MACHINE_TYPE = "e2-standard-4"; // 4 vCPUs, 16 GB memory
|
|
30
28
|
|
|
31
|
-
// Resolve the install script path. In source tree, use the file directly.
|
|
32
|
-
// In compiled binary ($bunfs), the file may not be available.
|
|
33
29
|
async function resolveInstallScriptPath(): Promise<string | null> {
|
|
34
30
|
const sourcePath = join(import.meta.dir, "..", "adapters", "install.sh");
|
|
35
31
|
if (existsSync(sourcePath)) {
|
|
@@ -44,28 +40,7 @@ const HATCH_TIMEOUT_MS: Record<Species, number> = {
|
|
|
44
40
|
};
|
|
45
41
|
const DEFAULT_SPECIES: Species = "vellum";
|
|
46
42
|
|
|
47
|
-
const
|
|
48
|
-
{
|
|
49
|
-
name: "allow-vellum-assistant-gateway",
|
|
50
|
-
direction: "INGRESS",
|
|
51
|
-
action: "ALLOW",
|
|
52
|
-
rules: `tcp:${GATEWAY_PORT}`,
|
|
53
|
-
sourceRanges: "0.0.0.0/0",
|
|
54
|
-
targetTags: FIREWALL_TAG,
|
|
55
|
-
description: `Allow gateway ingress on port ${GATEWAY_PORT} for vellum-assistant instances`,
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
name: "allow-vellum-assistant-egress",
|
|
59
|
-
direction: "EGRESS",
|
|
60
|
-
action: "ALLOW",
|
|
61
|
-
rules: "all",
|
|
62
|
-
destinationRanges: "0.0.0.0/0",
|
|
63
|
-
targetTags: FIREWALL_TAG,
|
|
64
|
-
description: "Allow all egress traffic for vellum-assistant instances",
|
|
65
|
-
},
|
|
66
|
-
];
|
|
67
|
-
|
|
68
|
-
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
43
|
+
const SPINNER_FRAMES= ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
69
44
|
|
|
70
45
|
function buildTimestampRedirect(): string {
|
|
71
46
|
return `exec > >(while IFS= read -r line; do printf '[%s] %s\\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$line"; done > /var/log/startup-script.log) 2>&1`;
|
|
@@ -215,58 +190,6 @@ function parseArgs(): HatchArgs {
|
|
|
215
190
|
return { species, detached, name, remote, daemonOnly };
|
|
216
191
|
}
|
|
217
192
|
|
|
218
|
-
export interface PollResult {
|
|
219
|
-
lastLine: string | null;
|
|
220
|
-
done: boolean;
|
|
221
|
-
failed: boolean;
|
|
222
|
-
errorContent: string;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async function pollInstance(
|
|
226
|
-
instanceName: string,
|
|
227
|
-
project: string,
|
|
228
|
-
zone: string,
|
|
229
|
-
account?: string,
|
|
230
|
-
): Promise<PollResult> {
|
|
231
|
-
try {
|
|
232
|
-
const remoteCmd =
|
|
233
|
-
"L=$(tail -1 /var/log/startup-script.log 2>/dev/null || true); " +
|
|
234
|
-
"S=$(systemctl is-active google-startup-scripts.service 2>/dev/null || true); " +
|
|
235
|
-
"E=$(cat /var/log/startup-error 2>/dev/null || true); " +
|
|
236
|
-
'printf "%s\\n===HATCH_SEP===\\n%s\\n===HATCH_ERR===\\n%s" "$L" "$S" "$E"';
|
|
237
|
-
const args = [
|
|
238
|
-
"compute",
|
|
239
|
-
"ssh",
|
|
240
|
-
instanceName,
|
|
241
|
-
`--project=${project}`,
|
|
242
|
-
`--zone=${zone}`,
|
|
243
|
-
"--quiet",
|
|
244
|
-
"--ssh-flag=-o StrictHostKeyChecking=no",
|
|
245
|
-
"--ssh-flag=-o UserKnownHostsFile=/dev/null",
|
|
246
|
-
"--ssh-flag=-o ConnectTimeout=10",
|
|
247
|
-
"--ssh-flag=-o LogLevel=ERROR",
|
|
248
|
-
`--command=${remoteCmd}`,
|
|
249
|
-
];
|
|
250
|
-
if (account) args.push(`--account=${account}`);
|
|
251
|
-
const output = await execOutput("gcloud", args);
|
|
252
|
-
const sepIdx = output.indexOf("===HATCH_SEP===");
|
|
253
|
-
if (sepIdx === -1) {
|
|
254
|
-
return { lastLine: output.trim() || null, done: false, failed: false, errorContent: "" };
|
|
255
|
-
}
|
|
256
|
-
const errIdx = output.indexOf("===HATCH_ERR===");
|
|
257
|
-
const lastLine = output.substring(0, sepIdx).trim() || null;
|
|
258
|
-
const statusEnd = errIdx === -1 ? undefined : errIdx;
|
|
259
|
-
const status = output.substring(sepIdx + "===HATCH_SEP===".length, statusEnd).trim();
|
|
260
|
-
const errorContent =
|
|
261
|
-
errIdx === -1 ? "" : output.substring(errIdx + "===HATCH_ERR===".length).trim();
|
|
262
|
-
const done = lastLine !== null && status !== "active" && status !== "activating";
|
|
263
|
-
const failed = errorContent.length > 0 || status === "failed";
|
|
264
|
-
return { lastLine, done, failed, errorContent };
|
|
265
|
-
} catch {
|
|
266
|
-
return { lastLine: null, done: false, failed: false, errorContent: "" };
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
193
|
function formatElapsed(ms: number): string {
|
|
271
194
|
const secs = Math.floor(ms / 1000);
|
|
272
195
|
const m = Math.floor(secs / 60);
|
|
@@ -286,77 +209,6 @@ function getPhaseIcon(hasLogs: boolean, elapsedMs: number, species: Species): st
|
|
|
286
209
|
return elapsedMs < 120000 ? "🐣" : SPECIES_CONFIG[species].hatchedEmoji;
|
|
287
210
|
}
|
|
288
211
|
|
|
289
|
-
async function checkCurlFailure(
|
|
290
|
-
instanceName: string,
|
|
291
|
-
project: string,
|
|
292
|
-
zone: string,
|
|
293
|
-
account?: string,
|
|
294
|
-
): Promise<boolean> {
|
|
295
|
-
try {
|
|
296
|
-
const args = [
|
|
297
|
-
"compute",
|
|
298
|
-
"ssh",
|
|
299
|
-
instanceName,
|
|
300
|
-
`--project=${project}`,
|
|
301
|
-
`--zone=${zone}`,
|
|
302
|
-
"--quiet",
|
|
303
|
-
"--ssh-flag=-o StrictHostKeyChecking=no",
|
|
304
|
-
"--ssh-flag=-o UserKnownHostsFile=/dev/null",
|
|
305
|
-
"--ssh-flag=-o ConnectTimeout=10",
|
|
306
|
-
"--ssh-flag=-o LogLevel=ERROR",
|
|
307
|
-
`--command=test -s ${INSTALL_SCRIPT_REMOTE_PATH} && echo EXISTS || echo MISSING`,
|
|
308
|
-
];
|
|
309
|
-
if (account) args.push(`--account=${account}`);
|
|
310
|
-
const output = await execOutput("gcloud", args);
|
|
311
|
-
return output.trim() === "MISSING";
|
|
312
|
-
} catch {
|
|
313
|
-
return false;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
async function recoverFromCurlFailure(
|
|
318
|
-
instanceName: string,
|
|
319
|
-
project: string,
|
|
320
|
-
zone: string,
|
|
321
|
-
sshUser: string,
|
|
322
|
-
account?: string,
|
|
323
|
-
): Promise<void> {
|
|
324
|
-
const installScriptPath = await resolveInstallScriptPath();
|
|
325
|
-
if (!installScriptPath) {
|
|
326
|
-
console.warn("⚠️ Skipping install script upload (not available in compiled binary)");
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const scpArgs = [
|
|
331
|
-
"compute",
|
|
332
|
-
"scp",
|
|
333
|
-
installScriptPath,
|
|
334
|
-
`${instanceName}:${INSTALL_SCRIPT_REMOTE_PATH}`,
|
|
335
|
-
`--zone=${zone}`,
|
|
336
|
-
`--project=${project}`,
|
|
337
|
-
];
|
|
338
|
-
if (account) scpArgs.push(`--account=${account}`);
|
|
339
|
-
console.log("📋 Uploading install script to instance...");
|
|
340
|
-
await exec("gcloud", scpArgs);
|
|
341
|
-
|
|
342
|
-
const sshArgs = [
|
|
343
|
-
"compute",
|
|
344
|
-
"ssh",
|
|
345
|
-
`${sshUser}@${instanceName}`,
|
|
346
|
-
`--zone=${zone}`,
|
|
347
|
-
`--project=${project}`,
|
|
348
|
-
`--command=source ${INSTALL_SCRIPT_REMOTE_PATH}`,
|
|
349
|
-
];
|
|
350
|
-
if (account) sshArgs.push(`--account=${account}`);
|
|
351
|
-
console.log("🔧 Running install script on instance...");
|
|
352
|
-
await exec("gcloud", sshArgs);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
export interface WatchHatchingResult {
|
|
356
|
-
success: boolean;
|
|
357
|
-
errorContent: string;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
212
|
export async function watchHatching(
|
|
361
213
|
pollFn: () => Promise<PollResult>,
|
|
362
214
|
instanceName: string,
|
|
@@ -469,197 +321,6 @@ export async function watchHatching(
|
|
|
469
321
|
});
|
|
470
322
|
}
|
|
471
323
|
|
|
472
|
-
|
|
473
|
-
async function hatchGcp(
|
|
474
|
-
species: Species,
|
|
475
|
-
detached: boolean,
|
|
476
|
-
name: string | null,
|
|
477
|
-
): Promise<void> {
|
|
478
|
-
const startTime = Date.now();
|
|
479
|
-
const account = process.env.GCP_ACCOUNT_EMAIL;
|
|
480
|
-
try {
|
|
481
|
-
const project = process.env.GCP_PROJECT ?? (await getActiveProject());
|
|
482
|
-
let instanceName: string;
|
|
483
|
-
|
|
484
|
-
if (name) {
|
|
485
|
-
instanceName = name;
|
|
486
|
-
} else {
|
|
487
|
-
const suffix = generateRandomSuffix();
|
|
488
|
-
instanceName = `${species}-${suffix}`;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
console.log(`🥚 Creating new assistant: ${instanceName}`);
|
|
492
|
-
console.log(` Species: ${species}`);
|
|
493
|
-
console.log(` Cloud: GCP`);
|
|
494
|
-
console.log(` Project: ${project}`);
|
|
495
|
-
const zone = process.env.GCP_DEFAULT_ZONE;
|
|
496
|
-
if (!zone) {
|
|
497
|
-
console.error("Error: GCP_DEFAULT_ZONE environment variable is not set.");
|
|
498
|
-
process.exit(1);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
console.log(` Zone: ${zone}`);
|
|
502
|
-
console.log(` Machine type: ${MACHINE_TYPE}`);
|
|
503
|
-
console.log("");
|
|
504
|
-
|
|
505
|
-
if (name) {
|
|
506
|
-
if (await instanceExists(name, project, zone, account)) {
|
|
507
|
-
console.error(
|
|
508
|
-
`Error: Instance name '${name}' is already taken. Please choose a different name.`,
|
|
509
|
-
);
|
|
510
|
-
process.exit(1);
|
|
511
|
-
}
|
|
512
|
-
} else {
|
|
513
|
-
while (await instanceExists(instanceName, project, zone, account)) {
|
|
514
|
-
console.log(`⚠️ Instance name ${instanceName} already exists, generating a new name...`);
|
|
515
|
-
const suffix = generateRandomSuffix();
|
|
516
|
-
instanceName = `${species}-${suffix}`;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
const sshUser = userInfo().username;
|
|
521
|
-
const bearerToken = randomBytes(32).toString("hex");
|
|
522
|
-
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
523
|
-
if (!anthropicApiKey) {
|
|
524
|
-
console.error("Error: ANTHROPIC_API_KEY environment variable is not set.");
|
|
525
|
-
process.exit(1);
|
|
526
|
-
}
|
|
527
|
-
const startupScript = await buildStartupScript(
|
|
528
|
-
species,
|
|
529
|
-
bearerToken,
|
|
530
|
-
sshUser,
|
|
531
|
-
anthropicApiKey,
|
|
532
|
-
instanceName,
|
|
533
|
-
"gcp",
|
|
534
|
-
);
|
|
535
|
-
const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
|
|
536
|
-
writeFileSync(startupScriptPath, startupScript);
|
|
537
|
-
|
|
538
|
-
console.log("🔨 Creating instance with startup script...");
|
|
539
|
-
try {
|
|
540
|
-
const createArgs = [
|
|
541
|
-
"compute",
|
|
542
|
-
"instances",
|
|
543
|
-
"create",
|
|
544
|
-
instanceName,
|
|
545
|
-
`--project=${project}`,
|
|
546
|
-
`--zone=${zone}`,
|
|
547
|
-
`--machine-type=${MACHINE_TYPE}`,
|
|
548
|
-
"--image-family=debian-11",
|
|
549
|
-
"--image-project=debian-cloud",
|
|
550
|
-
"--boot-disk-size=50GB",
|
|
551
|
-
"--boot-disk-type=pd-standard",
|
|
552
|
-
`--metadata-from-file=startup-script=${startupScriptPath}`,
|
|
553
|
-
`--labels=species=${species},vellum-assistant=true`,
|
|
554
|
-
"--tags=vellum-assistant",
|
|
555
|
-
];
|
|
556
|
-
if (account) createArgs.push(`--account=${account}`);
|
|
557
|
-
await exec("gcloud", createArgs);
|
|
558
|
-
} finally {
|
|
559
|
-
try {
|
|
560
|
-
unlinkSync(startupScriptPath);
|
|
561
|
-
} catch {}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
console.log("🔒 Syncing firewall rules...");
|
|
565
|
-
await syncFirewallRules(DESIRED_FIREWALL_RULES, project, FIREWALL_TAG, account);
|
|
566
|
-
|
|
567
|
-
console.log(`✅ Instance ${instanceName} created successfully\n`);
|
|
568
|
-
|
|
569
|
-
let externalIp: string | null = null;
|
|
570
|
-
try {
|
|
571
|
-
const describeArgs = [
|
|
572
|
-
"compute",
|
|
573
|
-
"instances",
|
|
574
|
-
"describe",
|
|
575
|
-
instanceName,
|
|
576
|
-
`--project=${project}`,
|
|
577
|
-
`--zone=${zone}`,
|
|
578
|
-
"--format=get(networkInterfaces[0].accessConfigs[0].natIP)",
|
|
579
|
-
];
|
|
580
|
-
if (account) describeArgs.push(`--account=${account}`);
|
|
581
|
-
const ipOutput = await execOutput("gcloud", describeArgs);
|
|
582
|
-
externalIp = ipOutput.trim() || null;
|
|
583
|
-
} catch {
|
|
584
|
-
console.log("⚠️ Could not retrieve external IP yet (instance may still be starting)");
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const runtimeUrl = externalIp
|
|
588
|
-
? `http://${externalIp}:${GATEWAY_PORT}`
|
|
589
|
-
: `http://${instanceName}:${GATEWAY_PORT}`;
|
|
590
|
-
const gcpEntry: AssistantEntry = {
|
|
591
|
-
assistantId: instanceName,
|
|
592
|
-
runtimeUrl,
|
|
593
|
-
bearerToken,
|
|
594
|
-
cloud: "gcp",
|
|
595
|
-
project,
|
|
596
|
-
zone,
|
|
597
|
-
species,
|
|
598
|
-
sshUser,
|
|
599
|
-
hatchedAt: new Date().toISOString(),
|
|
600
|
-
};
|
|
601
|
-
saveAssistantEntry(gcpEntry);
|
|
602
|
-
|
|
603
|
-
if (detached) {
|
|
604
|
-
console.log("🚀 Startup script is running on the instance...");
|
|
605
|
-
console.log("");
|
|
606
|
-
console.log("✅ Assistant is hatching!\n");
|
|
607
|
-
console.log("Instance details:");
|
|
608
|
-
console.log(` Name: ${instanceName}`);
|
|
609
|
-
console.log(` Project: ${project}`);
|
|
610
|
-
console.log(` Zone: ${zone}`);
|
|
611
|
-
if (externalIp) {
|
|
612
|
-
console.log(` External IP: ${externalIp}`);
|
|
613
|
-
}
|
|
614
|
-
console.log("");
|
|
615
|
-
} else {
|
|
616
|
-
console.log(" Press Ctrl+C to detach (instance will keep running)");
|
|
617
|
-
console.log("");
|
|
618
|
-
|
|
619
|
-
const result = await watchHatching(
|
|
620
|
-
() => pollInstance(instanceName, project, zone, account),
|
|
621
|
-
instanceName,
|
|
622
|
-
startTime,
|
|
623
|
-
species,
|
|
624
|
-
);
|
|
625
|
-
|
|
626
|
-
if (!result.success) {
|
|
627
|
-
console.log("");
|
|
628
|
-
if (result.errorContent) {
|
|
629
|
-
console.log("📋 Startup error:");
|
|
630
|
-
console.log(` ${result.errorContent}`);
|
|
631
|
-
console.log("");
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
await fetchAndDisplayStartupLogs(instanceName, project, zone, account);
|
|
635
|
-
|
|
636
|
-
if (
|
|
637
|
-
species === "vellum" &&
|
|
638
|
-
(await checkCurlFailure(instanceName, project, zone, account))
|
|
639
|
-
) {
|
|
640
|
-
const installScriptUrl = `${process.env.VELLUM_ASSISTANT_PLATFORM_URL ?? "https://assistant.vellum.ai"}/install.sh`;
|
|
641
|
-
console.log(`🔄 Detected install script curl failure for ${installScriptUrl}, attempting recovery...`);
|
|
642
|
-
await recoverFromCurlFailure(instanceName, project, zone, sshUser, account);
|
|
643
|
-
console.log("✅ Recovery successful!");
|
|
644
|
-
} else {
|
|
645
|
-
process.exit(1);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
console.log("Instance details:");
|
|
650
|
-
console.log(` Name: ${instanceName}`);
|
|
651
|
-
console.log(` Project: ${project}`);
|
|
652
|
-
console.log(` Zone: ${zone}`);
|
|
653
|
-
if (externalIp) {
|
|
654
|
-
console.log(` External IP: ${externalIp}`);
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
} catch (error) {
|
|
658
|
-
console.error("❌ Error:", error instanceof Error ? error.message : error);
|
|
659
|
-
process.exit(1);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
324
|
function buildSshArgs(host: string): string[] {
|
|
664
325
|
return [
|
|
665
326
|
host,
|
|
@@ -780,117 +441,6 @@ async function hatchCustom(
|
|
|
780
441
|
}
|
|
781
442
|
}
|
|
782
443
|
|
|
783
|
-
function isGatewaySourceDir(dir: string): boolean {
|
|
784
|
-
return existsSync(join(dir, "package.json")) && existsSync(join(dir, "src", "index.ts"));
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
function findGatewaySourceFromCwd(): string | undefined {
|
|
788
|
-
let current = process.cwd();
|
|
789
|
-
while (true) {
|
|
790
|
-
if (isGatewaySourceDir(current)) {
|
|
791
|
-
return current;
|
|
792
|
-
}
|
|
793
|
-
const nestedCandidate = join(current, "gateway");
|
|
794
|
-
if (isGatewaySourceDir(nestedCandidate)) {
|
|
795
|
-
return nestedCandidate;
|
|
796
|
-
}
|
|
797
|
-
const parent = dirname(current);
|
|
798
|
-
if (parent === current) {
|
|
799
|
-
return undefined;
|
|
800
|
-
}
|
|
801
|
-
current = parent;
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
function resolveGatewayDir(): string {
|
|
806
|
-
const override = process.env.VELLUM_GATEWAY_DIR?.trim();
|
|
807
|
-
if (override) {
|
|
808
|
-
if (!isGatewaySourceDir(override)) {
|
|
809
|
-
throw new Error(
|
|
810
|
-
`VELLUM_GATEWAY_DIR is set to "${override}", but it is not a valid gateway source directory.`,
|
|
811
|
-
);
|
|
812
|
-
}
|
|
813
|
-
return override;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
const sourceDir = join(import.meta.dir, "..", "..", "..", "gateway");
|
|
817
|
-
if (isGatewaySourceDir(sourceDir)) {
|
|
818
|
-
return sourceDir;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
const cwdSourceDir = findGatewaySourceFromCwd();
|
|
822
|
-
if (cwdSourceDir) {
|
|
823
|
-
return cwdSourceDir;
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
try {
|
|
827
|
-
const pkgPath = _require.resolve("@vellumai/vellum-gateway/package.json");
|
|
828
|
-
return dirname(pkgPath);
|
|
829
|
-
} catch {
|
|
830
|
-
throw new Error(
|
|
831
|
-
"Gateway not found. Ensure @vellumai/vellum-gateway is installed, run from the source tree, or set VELLUM_GATEWAY_DIR.",
|
|
832
|
-
);
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
function normalizeIngressUrl(value: unknown): string | undefined {
|
|
837
|
-
if (typeof value !== "string") return undefined;
|
|
838
|
-
const normalized = value.trim().replace(/\/+$/, "");
|
|
839
|
-
return normalized || undefined;
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
function readWorkspaceIngressPublicBaseUrl(): string | undefined {
|
|
843
|
-
const baseDataDir = process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir());
|
|
844
|
-
const workspaceConfigPath = join(baseDataDir, ".vellum", "workspace", "config.json");
|
|
845
|
-
try {
|
|
846
|
-
const raw = JSON.parse(readFileSync(workspaceConfigPath, "utf-8")) as Record<string, unknown>;
|
|
847
|
-
const ingress = raw.ingress as Record<string, unknown> | undefined;
|
|
848
|
-
return normalizeIngressUrl(ingress?.publicBaseUrl);
|
|
849
|
-
} catch {
|
|
850
|
-
return undefined;
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
async function discoverPublicUrl(): Promise<string | undefined> {
|
|
855
|
-
const cloud = process.env.VELLUM_CLOUD;
|
|
856
|
-
if (!cloud || cloud === "local") {
|
|
857
|
-
return `http://localhost:${GATEWAY_PORT}`;
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
let externalIp: string | undefined;
|
|
861
|
-
try {
|
|
862
|
-
if (cloud === "gcp") {
|
|
863
|
-
const resp = await fetch(
|
|
864
|
-
"http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
|
|
865
|
-
{ headers: { "Metadata-Flavor": "Google" } },
|
|
866
|
-
);
|
|
867
|
-
if (resp.ok) externalIp = (await resp.text()).trim();
|
|
868
|
-
} else if (cloud === "aws") {
|
|
869
|
-
// Use IMDSv2 (token-based) for compatibility with HttpTokens=required
|
|
870
|
-
const tokenResp = await fetch(
|
|
871
|
-
"http://169.254.169.254/latest/api/token",
|
|
872
|
-
{ method: "PUT", headers: { "X-aws-ec2-metadata-token-ttl-seconds": "30" } },
|
|
873
|
-
);
|
|
874
|
-
if (tokenResp.ok) {
|
|
875
|
-
const token = await tokenResp.text();
|
|
876
|
-
const ipResp = await fetch(
|
|
877
|
-
"http://169.254.169.254/latest/meta-data/public-ipv4",
|
|
878
|
-
{ headers: { "X-aws-ec2-metadata-token": token } },
|
|
879
|
-
);
|
|
880
|
-
if (ipResp.ok) externalIp = (await ipResp.text()).trim();
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
} catch {
|
|
884
|
-
// metadata service not reachable
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
if (externalIp) {
|
|
888
|
-
console.log(` Discovered external IP: ${externalIp}`);
|
|
889
|
-
return `http://${externalIp}:${GATEWAY_PORT}`;
|
|
890
|
-
}
|
|
891
|
-
return undefined;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
444
|
async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false): Promise<void> {
|
|
895
445
|
const instanceName =
|
|
896
446
|
name ?? process.env.VELLUM_ASSISTANT_NAME ?? `${species}-${generateRandomSuffix()}`;
|
|
@@ -899,152 +449,7 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo
|
|
|
899
449
|
console.log(` Species: ${species}`);
|
|
900
450
|
console.log("");
|
|
901
451
|
|
|
902
|
-
|
|
903
|
-
// When running inside the desktop app, the CLI owns the daemon lifecycle.
|
|
904
|
-
// Find the vellum-daemon binary adjacent to the CLI binary.
|
|
905
|
-
const daemonBinary = join(dirname(process.execPath), "vellum-daemon");
|
|
906
|
-
if (!existsSync(daemonBinary)) {
|
|
907
|
-
throw new Error(
|
|
908
|
-
`vellum-daemon binary not found at ${daemonBinary}.\n` +
|
|
909
|
-
" Ensure the daemon binary is bundled alongside the CLI in the app bundle.",
|
|
910
|
-
);
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
const vellumDir = join(homedir(), ".vellum");
|
|
914
|
-
const pidFile = join(vellumDir, "vellum.pid");
|
|
915
|
-
const socketFile = join(vellumDir, "vellum.sock");
|
|
916
|
-
|
|
917
|
-
// If a daemon is already running, skip spawning a new one.
|
|
918
|
-
// This prevents cascading kill→restart cycles when multiple callers
|
|
919
|
-
// invoke hatch() concurrently (setupDaemonClient + ensureDaemonConnected).
|
|
920
|
-
let daemonAlive = false;
|
|
921
|
-
if (existsSync(pidFile)) {
|
|
922
|
-
try {
|
|
923
|
-
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
924
|
-
if (!isNaN(pid)) {
|
|
925
|
-
try {
|
|
926
|
-
process.kill(pid, 0); // Check if alive
|
|
927
|
-
daemonAlive = true;
|
|
928
|
-
console.log(` Daemon already running (pid ${pid})\n`);
|
|
929
|
-
} catch {
|
|
930
|
-
// Process doesn't exist, clean up stale PID file
|
|
931
|
-
try { unlinkSync(pidFile); } catch {}
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
} catch {}
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
if (!daemonAlive) {
|
|
938
|
-
// Remove stale socket so we can detect the fresh one
|
|
939
|
-
try { unlinkSync(socketFile); } catch {}
|
|
940
|
-
|
|
941
|
-
console.log("🔨 Starting daemon...");
|
|
942
|
-
|
|
943
|
-
// Ensure ~/.vellum/ exists for PID/socket files
|
|
944
|
-
mkdirSync(vellumDir, { recursive: true });
|
|
945
|
-
|
|
946
|
-
// Build a minimal environment for the daemon. When launched from the
|
|
947
|
-
// macOS app the CLI inherits a huge environment (XPC_SERVICE_NAME,
|
|
948
|
-
// __CFBundleIdentifier, CLAUDE_CODE_ENTRYPOINT, etc.) that can cause
|
|
949
|
-
// the daemon to take 50+ seconds to start instead of ~1s.
|
|
950
|
-
const daemonEnv: Record<string, string> = {
|
|
951
|
-
HOME: process.env.HOME || homedir(),
|
|
952
|
-
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
953
|
-
VELLUM_DAEMON_TCP_ENABLED: "1",
|
|
954
|
-
};
|
|
955
|
-
// Forward optional config env vars the daemon may need
|
|
956
|
-
for (const key of [
|
|
957
|
-
"ANTHROPIC_API_KEY",
|
|
958
|
-
"BASE_DATA_DIR",
|
|
959
|
-
"VELLUM_DAEMON_TCP_PORT",
|
|
960
|
-
"VELLUM_DAEMON_TCP_HOST",
|
|
961
|
-
"VELLUM_DAEMON_SOCKET",
|
|
962
|
-
"VELLUM_DEBUG",
|
|
963
|
-
"SENTRY_DSN",
|
|
964
|
-
"TMPDIR",
|
|
965
|
-
"USER",
|
|
966
|
-
"LANG",
|
|
967
|
-
]) {
|
|
968
|
-
if (process.env[key]) {
|
|
969
|
-
daemonEnv[key] = process.env[key]!;
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
const child = spawn(daemonBinary, [], {
|
|
974
|
-
detached: true,
|
|
975
|
-
stdio: "ignore",
|
|
976
|
-
env: daemonEnv,
|
|
977
|
-
});
|
|
978
|
-
child.unref();
|
|
979
|
-
|
|
980
|
-
// Write PID file immediately so the health monitor can find the process
|
|
981
|
-
// and concurrent hatch() calls see it as alive.
|
|
982
|
-
if (child.pid) {
|
|
983
|
-
writeFileSync(pidFile, String(child.pid), "utf-8");
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
// Wait for socket at ~/.vellum/vellum.sock (up to 15s)
|
|
988
|
-
if (!existsSync(socketFile)) {
|
|
989
|
-
const maxWait = 15000;
|
|
990
|
-
const start = Date.now();
|
|
991
|
-
while (Date.now() - start < maxWait) {
|
|
992
|
-
if (existsSync(socketFile)) {
|
|
993
|
-
break;
|
|
994
|
-
}
|
|
995
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
if (existsSync(socketFile)) {
|
|
999
|
-
console.log(" Daemon socket ready\n");
|
|
1000
|
-
} else {
|
|
1001
|
-
console.log(" ⚠️ Daemon socket did not appear within 15s — continuing anyway\n");
|
|
1002
|
-
}
|
|
1003
|
-
} else {
|
|
1004
|
-
console.log("🔨 Starting local daemon...");
|
|
1005
|
-
|
|
1006
|
-
// Source tree layout: cli/src/commands/ -> ../../.. -> repo root -> assistant/src/index.ts
|
|
1007
|
-
const sourceTreeIndex = join(import.meta.dir, "..", "..", "..", "assistant", "src", "index.ts");
|
|
1008
|
-
// bunx layout: @vellumai/cli/src/commands/ -> ../../../.. -> node_modules/ -> vellum/src/index.ts
|
|
1009
|
-
const bunxIndex = join(import.meta.dir, "..", "..", "..", "..", "vellum", "src", "index.ts");
|
|
1010
|
-
let assistantIndex = sourceTreeIndex;
|
|
1011
|
-
|
|
1012
|
-
if (!existsSync(assistantIndex)) {
|
|
1013
|
-
assistantIndex = bunxIndex;
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
if (!existsSync(assistantIndex)) {
|
|
1017
|
-
try {
|
|
1018
|
-
const vellumPkgPath = _require.resolve("vellum/package.json");
|
|
1019
|
-
assistantIndex = join(dirname(vellumPkgPath), "src", "index.ts");
|
|
1020
|
-
} catch {
|
|
1021
|
-
// resolve failed, will fall through to existsSync check below
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
if (!existsSync(assistantIndex)) {
|
|
1026
|
-
throw new Error(
|
|
1027
|
-
"vellum-daemon binary not found and assistant source not available.\n" +
|
|
1028
|
-
" Ensure the daemon binary is bundled alongside the CLI, or run from the source tree.",
|
|
1029
|
-
);
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
const child = spawn("bun", ["run", assistantIndex, "daemon", "start"], {
|
|
1033
|
-
stdio: "inherit",
|
|
1034
|
-
env: { ...process.env },
|
|
1035
|
-
});
|
|
1036
|
-
|
|
1037
|
-
await new Promise<void>((resolve, reject) => {
|
|
1038
|
-
child.on("close", (code) => {
|
|
1039
|
-
if (code === 0) {
|
|
1040
|
-
resolve();
|
|
1041
|
-
} else {
|
|
1042
|
-
reject(new Error(`Daemon start exited with code ${code}`));
|
|
1043
|
-
}
|
|
1044
|
-
});
|
|
1045
|
-
child.on("error", reject);
|
|
1046
|
-
});
|
|
1047
|
-
}
|
|
452
|
+
await startLocalDaemon();
|
|
1048
453
|
|
|
1049
454
|
// The desktop app communicates with the daemon directly via Unix socket,
|
|
1050
455
|
// so the HTTP gateway is only needed for non-desktop (CLI) usage.
|
|
@@ -1054,40 +459,7 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo
|
|
|
1054
459
|
// No gateway needed — the macOS app uses DaemonClient over the Unix socket.
|
|
1055
460
|
runtimeUrl = "local";
|
|
1056
461
|
} else {
|
|
1057
|
-
|
|
1058
|
-
if (publicUrl) {
|
|
1059
|
-
console.log(` Public URL: ${publicUrl}`);
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
console.log("🌐 Starting gateway...");
|
|
1063
|
-
const gatewayDir = resolveGatewayDir();
|
|
1064
|
-
const gatewayEnv: Record<string, string> = {
|
|
1065
|
-
...process.env as Record<string, string>,
|
|
1066
|
-
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
1067
|
-
GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false",
|
|
1068
|
-
};
|
|
1069
|
-
const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl();
|
|
1070
|
-
const ingressPublicBaseUrl =
|
|
1071
|
-
workspaceIngressPublicBaseUrl
|
|
1072
|
-
?? normalizeIngressUrl(process.env.INGRESS_PUBLIC_BASE_URL);
|
|
1073
|
-
if (ingressPublicBaseUrl) {
|
|
1074
|
-
gatewayEnv.INGRESS_PUBLIC_BASE_URL = ingressPublicBaseUrl;
|
|
1075
|
-
console.log(` Ingress URL: ${ingressPublicBaseUrl}`);
|
|
1076
|
-
if (!workspaceIngressPublicBaseUrl) {
|
|
1077
|
-
console.log(" (using INGRESS_PUBLIC_BASE_URL env fallback)");
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
if (publicUrl) gatewayEnv.GATEWAY_PUBLIC_URL = publicUrl;
|
|
1081
|
-
|
|
1082
|
-
const gateway = spawn("bun", ["run", "src/index.ts"], {
|
|
1083
|
-
cwd: gatewayDir,
|
|
1084
|
-
detached: true,
|
|
1085
|
-
stdio: "ignore",
|
|
1086
|
-
env: gatewayEnv,
|
|
1087
|
-
});
|
|
1088
|
-
gateway.unref();
|
|
1089
|
-
console.log("✅ Gateway started\n");
|
|
1090
|
-
runtimeUrl = publicUrl || `http://localhost:${GATEWAY_PORT}`;
|
|
462
|
+
runtimeUrl = await startGateway();
|
|
1091
463
|
}
|
|
1092
464
|
|
|
1093
465
|
const baseDataDir = join(process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? userInfo().homedir), ".vellum");
|
|
@@ -1135,7 +507,7 @@ export async function hatch(): Promise<void> {
|
|
|
1135
507
|
}
|
|
1136
508
|
|
|
1137
509
|
if (remote === "gcp") {
|
|
1138
|
-
await hatchGcp(species, detached, name);
|
|
510
|
+
await hatchGcp(species, detached, name, buildStartupScript, watchHatching);
|
|
1139
511
|
return;
|
|
1140
512
|
}
|
|
1141
513
|
|