@vellumai/cli 0.1.10 → 0.1.12
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/bun.lock +7 -0
- package/package.json +9 -2
- package/src/commands/client.ts +110 -0
- package/src/commands/hatch.ts +66 -640
- package/src/commands/ps.ts +232 -2
- package/src/commands/retire.ts +9 -41
- package/src/commands/sleep.ts +25 -53
- package/src/commands/wake.ts +17 -2
- package/src/components/DefaultMainScreen.tsx +1756 -49
- package/src/components/TextInput.tsx +115 -0
- package/src/index.ts +4 -1
- package/src/lib/constants.ts +1 -1
- package/src/lib/doctor-client.ts +127 -0
- package/src/lib/gcp.ts +412 -3
- package/src/lib/local.ts +323 -0
- package/src/lib/process.ts +93 -0
- package/src/lib/interfaces-seed.ts +0 -28
package/src/commands/hatch.ts
CHANGED
|
@@ -1,35 +1,30 @@
|
|
|
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 {
|
|
22
|
-
import {
|
|
18
|
+
import { hatchGcp } from "../lib/gcp";
|
|
19
|
+
import type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
20
|
+
import { startLocalDaemon, startGateway } from "../lib/local";
|
|
23
21
|
import { generateRandomSuffix } from "../lib/random-name";
|
|
24
|
-
import { exec
|
|
22
|
+
import { exec } from "../lib/step-runner";
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
export type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
27
25
|
|
|
28
26
|
const INSTALL_SCRIPT_REMOTE_PATH = "/tmp/vellum-install.sh";
|
|
29
|
-
const MACHINE_TYPE = "e2-standard-4"; // 4 vCPUs, 16 GB memory
|
|
30
27
|
|
|
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
28
|
async function resolveInstallScriptPath(): Promise<string | null> {
|
|
34
29
|
const sourcePath = join(import.meta.dir, "..", "adapters", "install.sh");
|
|
35
30
|
if (existsSync(sourcePath)) {
|
|
@@ -44,28 +39,13 @@ const HATCH_TIMEOUT_MS: Record<Species, number> = {
|
|
|
44
39
|
};
|
|
45
40
|
const DEFAULT_SPECIES: Species = "vellum";
|
|
46
41
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
42
|
+
const SPINNER_FRAMES= ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
43
|
+
|
|
44
|
+
const IS_DESKTOP = !!process.env.VELLUM_DESKTOP_APP;
|
|
45
|
+
|
|
46
|
+
function desktopLog(msg: string): void {
|
|
47
|
+
process.stdout.write(msg + "\n");
|
|
48
|
+
}
|
|
69
49
|
|
|
70
50
|
function buildTimestampRedirect(): string {
|
|
71
51
|
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`;
|
|
@@ -113,8 +93,6 @@ export async function buildStartupScript(
|
|
|
113
93
|
);
|
|
114
94
|
}
|
|
115
95
|
|
|
116
|
-
const interfacesSeed = await buildInterfacesSeed();
|
|
117
|
-
|
|
118
96
|
return `#!/bin/bash
|
|
119
97
|
set -e
|
|
120
98
|
|
|
@@ -127,13 +105,11 @@ GATEWAY_RUNTIME_PROXY_ENABLED=true
|
|
|
127
105
|
RUNTIME_PROXY_BEARER_TOKEN=${bearerToken}
|
|
128
106
|
VELLUM_ASSISTANT_NAME=${instanceName}
|
|
129
107
|
VELLUM_CLOUD=${cloud}
|
|
130
|
-
${interfacesSeed}
|
|
131
108
|
mkdir -p "\$HOME/.vellum"
|
|
132
109
|
cat > "\$HOME/.vellum/.env" << DOTENV_EOF
|
|
133
110
|
ANTHROPIC_API_KEY=\$ANTHROPIC_API_KEY
|
|
134
111
|
GATEWAY_RUNTIME_PROXY_ENABLED=\$GATEWAY_RUNTIME_PROXY_ENABLED
|
|
135
112
|
RUNTIME_PROXY_BEARER_TOKEN=\$RUNTIME_PROXY_BEARER_TOKEN
|
|
136
|
-
INTERFACES_SEED_DIR=\$INTERFACES_SEED_DIR
|
|
137
113
|
RUNTIME_HTTP_PORT=7821
|
|
138
114
|
VELLUM_CLOUD=\$VELLUM_CLOUD
|
|
139
115
|
DOTENV_EOF
|
|
@@ -215,58 +191,6 @@ function parseArgs(): HatchArgs {
|
|
|
215
191
|
return { species, detached, name, remote, daemonOnly };
|
|
216
192
|
}
|
|
217
193
|
|
|
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
194
|
function formatElapsed(ms: number): string {
|
|
271
195
|
const secs = Math.floor(ms / 1000);
|
|
272
196
|
const m = Math.floor(secs / 60);
|
|
@@ -286,83 +210,16 @@ function getPhaseIcon(hasLogs: boolean, elapsedMs: number, species: Species): st
|
|
|
286
210
|
return elapsedMs < 120000 ? "🐣" : SPECIES_CONFIG[species].hatchedEmoji;
|
|
287
211
|
}
|
|
288
212
|
|
|
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
213
|
export async function watchHatching(
|
|
361
214
|
pollFn: () => Promise<PollResult>,
|
|
362
215
|
instanceName: string,
|
|
363
216
|
startTime: number,
|
|
364
217
|
species: Species,
|
|
365
218
|
): Promise<WatchHatchingResult> {
|
|
219
|
+
if (IS_DESKTOP) {
|
|
220
|
+
return watchHatchingDesktop(pollFn, instanceName, startTime, species);
|
|
221
|
+
}
|
|
222
|
+
|
|
366
223
|
let spinnerIdx = 0;
|
|
367
224
|
let lastLogLine: string | null = null;
|
|
368
225
|
let linesDrawn = 0;
|
|
@@ -469,195 +326,68 @@ export async function watchHatching(
|
|
|
469
326
|
});
|
|
470
327
|
}
|
|
471
328
|
|
|
472
|
-
|
|
473
|
-
|
|
329
|
+
function watchHatchingDesktop(
|
|
330
|
+
pollFn: () => Promise<PollResult>,
|
|
331
|
+
instanceName: string,
|
|
332
|
+
startTime: number,
|
|
474
333
|
species: Species,
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const project = process.env.GCP_PROJECT ?? (await getActiveProject());
|
|
482
|
-
let instanceName: string;
|
|
334
|
+
): Promise<WatchHatchingResult> {
|
|
335
|
+
return new Promise<WatchHatchingResult>((resolve) => {
|
|
336
|
+
let prevLogLine: string | null = null;
|
|
337
|
+
let lastErrorContent = "";
|
|
338
|
+
let pollInFlight = false;
|
|
339
|
+
let nextPollAt = Date.now() + 15000;
|
|
483
340
|
|
|
484
|
-
|
|
485
|
-
instanceName = name;
|
|
486
|
-
} else {
|
|
487
|
-
const suffix = generateRandomSuffix();
|
|
488
|
-
instanceName = `${species}-${suffix}`;
|
|
489
|
-
}
|
|
341
|
+
desktopLog("Waiting for instance to start...");
|
|
490
342
|
|
|
491
|
-
|
|
492
|
-
|
|
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("");
|
|
343
|
+
const interval = setInterval(async () => {
|
|
344
|
+
const elapsed = Date.now() - startTime;
|
|
504
345
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
);
|
|
510
|
-
|
|
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}`;
|
|
346
|
+
if (elapsed >= HATCH_TIMEOUT_MS[species]) {
|
|
347
|
+
clearInterval(interval);
|
|
348
|
+
desktopLog(`Timed out after ${formatElapsed(elapsed)}. Instance is still running.`);
|
|
349
|
+
desktopLog(`Monitor with: vel logs ${instanceName}`);
|
|
350
|
+
resolve({ success: true, errorContent: lastErrorContent });
|
|
351
|
+
return;
|
|
517
352
|
}
|
|
518
|
-
}
|
|
519
353
|
|
|
520
|
-
|
|
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);
|
|
354
|
+
if (Date.now() < nextPollAt || pollInFlight) return;
|
|
537
355
|
|
|
538
|
-
|
|
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 {
|
|
356
|
+
pollInFlight = true;
|
|
559
357
|
try {
|
|
560
|
-
|
|
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
|
-
}
|
|
358
|
+
const result = await pollFn();
|
|
586
359
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
);
|
|
360
|
+
if (result.lastLine && result.lastLine !== prevLogLine) {
|
|
361
|
+
prevLogLine = result.lastLine;
|
|
362
|
+
desktopLog(result.lastLine);
|
|
363
|
+
}
|
|
625
364
|
|
|
626
|
-
if (!result.success) {
|
|
627
|
-
console.log("");
|
|
628
365
|
if (result.errorContent) {
|
|
629
|
-
|
|
630
|
-
console.log(` ${result.errorContent}`);
|
|
631
|
-
console.log("");
|
|
366
|
+
lastErrorContent = result.errorContent;
|
|
632
367
|
}
|
|
633
368
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
await recoverFromCurlFailure(instanceName, project, zone, sshUser, account);
|
|
643
|
-
console.log("✅ Recovery successful!");
|
|
644
|
-
} else {
|
|
645
|
-
process.exit(1);
|
|
369
|
+
if (result.done) {
|
|
370
|
+
clearInterval(interval);
|
|
371
|
+
if (result.failed) {
|
|
372
|
+
desktopLog("Startup script failed");
|
|
373
|
+
} else {
|
|
374
|
+
desktopLog("Your assistant has hatched!");
|
|
375
|
+
}
|
|
376
|
+
resolve({ success: !result.failed, errorContent: lastErrorContent });
|
|
646
377
|
}
|
|
378
|
+
} finally {
|
|
379
|
+
pollInFlight = false;
|
|
380
|
+
nextPollAt = Date.now() + 5000;
|
|
647
381
|
}
|
|
382
|
+
}, 5000);
|
|
648
383
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
} catch (error) {
|
|
658
|
-
console.error("❌ Error:", error instanceof Error ? error.message : error);
|
|
659
|
-
process.exit(1);
|
|
660
|
-
}
|
|
384
|
+
process.on("SIGINT", () => {
|
|
385
|
+
clearInterval(interval);
|
|
386
|
+
desktopLog("Detaching. Instance is still running.");
|
|
387
|
+
desktopLog(`Monitor with: vel logs ${instanceName}`);
|
|
388
|
+
process.exit(0);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
661
391
|
}
|
|
662
392
|
|
|
663
393
|
function buildSshArgs(host: string): string[] {
|
|
@@ -780,310 +510,6 @@ async function hatchCustom(
|
|
|
780
510
|
}
|
|
781
511
|
}
|
|
782
512
|
|
|
783
|
-
function isGatewaySourceDir(dir: string): boolean {
|
|
784
|
-
const pkgPath = join(dir, "package.json");
|
|
785
|
-
if (!existsSync(pkgPath) || !existsSync(join(dir, "src", "index.ts"))) return false;
|
|
786
|
-
try {
|
|
787
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
788
|
-
return pkg.name === "@vellumai/vellum-gateway";
|
|
789
|
-
} catch {
|
|
790
|
-
return false;
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
function findGatewaySourceFromCwd(): string | undefined {
|
|
795
|
-
let current = process.cwd();
|
|
796
|
-
while (true) {
|
|
797
|
-
if (isGatewaySourceDir(current)) {
|
|
798
|
-
return current;
|
|
799
|
-
}
|
|
800
|
-
const nestedCandidate = join(current, "gateway");
|
|
801
|
-
if (isGatewaySourceDir(nestedCandidate)) {
|
|
802
|
-
return nestedCandidate;
|
|
803
|
-
}
|
|
804
|
-
const parent = dirname(current);
|
|
805
|
-
if (parent === current) {
|
|
806
|
-
return undefined;
|
|
807
|
-
}
|
|
808
|
-
current = parent;
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
function resolveGatewayDir(): string {
|
|
813
|
-
const override = process.env.VELLUM_GATEWAY_DIR?.trim();
|
|
814
|
-
if (override) {
|
|
815
|
-
if (!isGatewaySourceDir(override)) {
|
|
816
|
-
throw new Error(
|
|
817
|
-
`VELLUM_GATEWAY_DIR is set to "${override}", but it is not a valid gateway source directory.`,
|
|
818
|
-
);
|
|
819
|
-
}
|
|
820
|
-
return override;
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
const sourceDir = join(import.meta.dir, "..", "..", "..", "gateway");
|
|
824
|
-
if (isGatewaySourceDir(sourceDir)) {
|
|
825
|
-
return sourceDir;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
const cwdSourceDir = findGatewaySourceFromCwd();
|
|
829
|
-
if (cwdSourceDir) {
|
|
830
|
-
return cwdSourceDir;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
try {
|
|
834
|
-
const pkgPath = _require.resolve("@vellumai/vellum-gateway/package.json");
|
|
835
|
-
return dirname(pkgPath);
|
|
836
|
-
} catch {
|
|
837
|
-
throw new Error(
|
|
838
|
-
"Gateway not found. Ensure @vellumai/vellum-gateway is installed, run from the source tree, or set VELLUM_GATEWAY_DIR.",
|
|
839
|
-
);
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
function normalizeIngressUrl(value: unknown): string | undefined {
|
|
844
|
-
if (typeof value !== "string") return undefined;
|
|
845
|
-
const normalized = value.trim().replace(/\/+$/, "");
|
|
846
|
-
return normalized || undefined;
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
function readWorkspaceIngressPublicBaseUrl(): string | undefined {
|
|
850
|
-
const baseDataDir = process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir());
|
|
851
|
-
const workspaceConfigPath = join(baseDataDir, ".vellum", "workspace", "config.json");
|
|
852
|
-
try {
|
|
853
|
-
const raw = JSON.parse(readFileSync(workspaceConfigPath, "utf-8")) as Record<string, unknown>;
|
|
854
|
-
const ingress = raw.ingress as Record<string, unknown> | undefined;
|
|
855
|
-
return normalizeIngressUrl(ingress?.publicBaseUrl);
|
|
856
|
-
} catch {
|
|
857
|
-
return undefined;
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
async function discoverPublicUrl(): Promise<string | undefined> {
|
|
862
|
-
const cloud = process.env.VELLUM_CLOUD;
|
|
863
|
-
if (!cloud || cloud === "local") {
|
|
864
|
-
return `http://localhost:${GATEWAY_PORT}`;
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
let externalIp: string | undefined;
|
|
868
|
-
try {
|
|
869
|
-
if (cloud === "gcp") {
|
|
870
|
-
const resp = await fetch(
|
|
871
|
-
"http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
|
|
872
|
-
{ headers: { "Metadata-Flavor": "Google" } },
|
|
873
|
-
);
|
|
874
|
-
if (resp.ok) externalIp = (await resp.text()).trim();
|
|
875
|
-
} else if (cloud === "aws") {
|
|
876
|
-
// Use IMDSv2 (token-based) for compatibility with HttpTokens=required
|
|
877
|
-
const tokenResp = await fetch(
|
|
878
|
-
"http://169.254.169.254/latest/api/token",
|
|
879
|
-
{ method: "PUT", headers: { "X-aws-ec2-metadata-token-ttl-seconds": "30" } },
|
|
880
|
-
);
|
|
881
|
-
if (tokenResp.ok) {
|
|
882
|
-
const token = await tokenResp.text();
|
|
883
|
-
const ipResp = await fetch(
|
|
884
|
-
"http://169.254.169.254/latest/meta-data/public-ipv4",
|
|
885
|
-
{ headers: { "X-aws-ec2-metadata-token": token } },
|
|
886
|
-
);
|
|
887
|
-
if (ipResp.ok) externalIp = (await ipResp.text()).trim();
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
} catch {
|
|
891
|
-
// metadata service not reachable
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
if (externalIp) {
|
|
895
|
-
console.log(` Discovered external IP: ${externalIp}`);
|
|
896
|
-
return `http://${externalIp}:${GATEWAY_PORT}`;
|
|
897
|
-
}
|
|
898
|
-
return undefined;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
export async function startLocalDaemon(): Promise<void> {
|
|
902
|
-
if (process.env.VELLUM_DESKTOP_APP) {
|
|
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
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
export async function startGateway(): Promise<string> {
|
|
1051
|
-
const publicUrl = await discoverPublicUrl();
|
|
1052
|
-
if (publicUrl) {
|
|
1053
|
-
console.log(` Public URL: ${publicUrl}`);
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
console.log("🌐 Starting gateway...");
|
|
1057
|
-
const gatewayDir = resolveGatewayDir();
|
|
1058
|
-
const gatewayEnv: Record<string, string> = {
|
|
1059
|
-
...process.env as Record<string, string>,
|
|
1060
|
-
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
1061
|
-
GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false",
|
|
1062
|
-
};
|
|
1063
|
-
const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl();
|
|
1064
|
-
const ingressPublicBaseUrl =
|
|
1065
|
-
workspaceIngressPublicBaseUrl
|
|
1066
|
-
?? normalizeIngressUrl(process.env.INGRESS_PUBLIC_BASE_URL);
|
|
1067
|
-
if (ingressPublicBaseUrl) {
|
|
1068
|
-
gatewayEnv.INGRESS_PUBLIC_BASE_URL = ingressPublicBaseUrl;
|
|
1069
|
-
console.log(` Ingress URL: ${ingressPublicBaseUrl}`);
|
|
1070
|
-
if (!workspaceIngressPublicBaseUrl) {
|
|
1071
|
-
console.log(" (using INGRESS_PUBLIC_BASE_URL env fallback)");
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
if (publicUrl) gatewayEnv.GATEWAY_PUBLIC_URL = publicUrl;
|
|
1075
|
-
|
|
1076
|
-
const gateway = spawn("bun", ["run", "src/index.ts"], {
|
|
1077
|
-
cwd: gatewayDir,
|
|
1078
|
-
detached: true,
|
|
1079
|
-
stdio: "ignore",
|
|
1080
|
-
env: gatewayEnv,
|
|
1081
|
-
});
|
|
1082
|
-
gateway.unref();
|
|
1083
|
-
console.log("✅ Gateway started\n");
|
|
1084
|
-
return publicUrl || `http://localhost:${GATEWAY_PORT}`;
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
513
|
async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false): Promise<void> {
|
|
1088
514
|
const instanceName =
|
|
1089
515
|
name ?? process.env.VELLUM_ASSISTANT_NAME ?? `${species}-${generateRandomSuffix()}`;
|
|
@@ -1150,7 +576,7 @@ export async function hatch(): Promise<void> {
|
|
|
1150
576
|
}
|
|
1151
577
|
|
|
1152
578
|
if (remote === "gcp") {
|
|
1153
|
-
await hatchGcp(species, detached, name);
|
|
579
|
+
await hatchGcp(species, detached, name, buildStartupScript, watchHatching);
|
|
1154
580
|
return;
|
|
1155
581
|
}
|
|
1156
582
|
|