@vellumai/cli 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,806 @@
1
+ import { spawn } from "child_process";
2
+ import { randomBytes } from "crypto";
3
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
4
+ import { homedir, tmpdir, userInfo } from "os";
5
+ import { join } from "path";
6
+
7
+ import { buildOpenclawStartupScript } from "../adapters/openclaw";
8
+ import {
9
+ FIREWALL_TAG,
10
+ GATEWAY_PORT,
11
+ SPECIES_CONFIG,
12
+ VALID_REMOTE_HOSTS,
13
+ VALID_SPECIES,
14
+ } from "../lib/constants";
15
+ import type { RemoteHost, Species } from "../lib/constants";
16
+ import type { FirewallRuleSpec } from "../lib/gcp";
17
+ import { getActiveProject, instanceExists, syncFirewallRules } from "../lib/gcp";
18
+ import { buildInterfacesSeed } from "../lib/interfaces-seed";
19
+ import { generateRandomSuffix } from "../lib/random-name";
20
+ import { exec, execOutput } from "../lib/step-runner";
21
+
22
+ const DEFAULT_ZONE = "us-central1-a";
23
+ const INSTALL_SCRIPT_REMOTE_PATH = "/tmp/vellum-install.sh";
24
+ const INSTALL_SCRIPT_PATH = join(import.meta.dir, "..", "adapters", "install.sh");
25
+ const MACHINE_TYPE = "e2-standard-4"; // 4 vCPUs, 16 GB memory
26
+ const HATCH_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
27
+ const DEFAULT_SPECIES: Species = "vellum";
28
+
29
+ const DESIRED_FIREWALL_RULES: FirewallRuleSpec[] = [
30
+ {
31
+ name: "allow-vellum-assistant-gateway",
32
+ direction: "INGRESS",
33
+ action: "ALLOW",
34
+ rules: `tcp:${GATEWAY_PORT}`,
35
+ sourceRanges: "0.0.0.0/0",
36
+ targetTags: FIREWALL_TAG,
37
+ description: `Allow gateway ingress on port ${GATEWAY_PORT} for vellum-assistant instances`,
38
+ },
39
+ {
40
+ name: "allow-vellum-assistant-egress",
41
+ direction: "EGRESS",
42
+ action: "ALLOW",
43
+ rules: "all",
44
+ destinationRanges: "0.0.0.0/0",
45
+ targetTags: FIREWALL_TAG,
46
+ description: "Allow all egress traffic for vellum-assistant instances",
47
+ },
48
+ ];
49
+
50
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
51
+
52
+ function buildTimestampRedirect(): string {
53
+ 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`;
54
+ }
55
+
56
+ function buildUserSetup(sshUser: string): string {
57
+ return `
58
+ SSH_USER="${sshUser}"
59
+ if ! id "$SSH_USER" &>/dev/null; then
60
+ useradd -m -s /bin/bash "$SSH_USER"
61
+ fi
62
+ SSH_USER_HOME=$(eval echo "~$SSH_USER")
63
+ mkdir -p "$SSH_USER_HOME"
64
+ export HOME="$SSH_USER_HOME"
65
+ `;
66
+ }
67
+
68
+ function buildOwnershipFixup(): string {
69
+ return `
70
+ chown -R "$SSH_USER:$SSH_USER" "$SSH_USER_HOME" 2>/dev/null || true
71
+ `;
72
+ }
73
+
74
+ function buildStartupScript(
75
+ species: Species,
76
+ bearerToken: string,
77
+ sshUser: string,
78
+ anthropicApiKey: string,
79
+ ): string {
80
+ const timestampRedirect = buildTimestampRedirect();
81
+ const userSetup = buildUserSetup(sshUser);
82
+ const ownershipFixup = buildOwnershipFixup();
83
+
84
+ if (species === "openclaw") {
85
+ return buildOpenclawStartupScript(
86
+ bearerToken,
87
+ sshUser,
88
+ anthropicApiKey,
89
+ timestampRedirect,
90
+ userSetup,
91
+ ownershipFixup,
92
+ );
93
+ }
94
+
95
+ const interfacesSeed = buildInterfacesSeed();
96
+
97
+ return `#!/bin/bash
98
+ set -e
99
+
100
+ ${timestampRedirect}
101
+
102
+ trap 'EXIT_CODE=\$?; if [ \$EXIT_CODE -ne 0 ]; then echo "Startup script failed with exit code \$EXIT_CODE" > /var/log/startup-error; fi' EXIT
103
+ ${userSetup}
104
+ ANTHROPIC_API_KEY=${anthropicApiKey}
105
+ GATEWAY_RUNTIME_PROXY_ENABLED=true
106
+ RUNTIME_PROXY_BEARER_TOKEN=${bearerToken}
107
+ ${interfacesSeed}
108
+ mkdir -p "\$HOME/.vellum"
109
+ cat > "\$HOME/.vellum/.env" << DOTENV_EOF
110
+ ANTHROPIC_API_KEY=\$ANTHROPIC_API_KEY
111
+ GATEWAY_RUNTIME_PROXY_ENABLED=\$GATEWAY_RUNTIME_PROXY_ENABLED
112
+ RUNTIME_PROXY_BEARER_TOKEN=\$RUNTIME_PROXY_BEARER_TOKEN
113
+ INTERFACES_SEED_DIR=\$INTERFACES_SEED_DIR
114
+ DOTENV_EOF
115
+
116
+ mkdir -p "\$HOME/.vellum/workspace"
117
+ cat > "\$HOME/.vellum/workspace/config.json" << CONFIG_EOF
118
+ {
119
+ "logFile": {
120
+ "dir": "\$HOME/.vellum/workspace/data/logs"
121
+ }
122
+ }
123
+ CONFIG_EOF
124
+
125
+ ${ownershipFixup}
126
+
127
+ export VELLUM_SSH_USER="\$SSH_USER"
128
+ curl -fsSL https://assistant.vellum.ai/install.sh -o ${INSTALL_SCRIPT_REMOTE_PATH}
129
+ chmod +x ${INSTALL_SCRIPT_REMOTE_PATH}
130
+ source ${INSTALL_SCRIPT_REMOTE_PATH}
131
+ `;
132
+ }
133
+
134
+ const DEFAULT_REMOTE: RemoteHost = "local";
135
+
136
+ interface HatchArgs {
137
+ species: Species;
138
+ detached: boolean;
139
+ name: string | null;
140
+ remote: RemoteHost;
141
+ }
142
+
143
+ function parseArgs(): HatchArgs {
144
+ const args = process.argv.slice(3);
145
+ let species: Species = DEFAULT_SPECIES;
146
+ let detached = false;
147
+ let name: string | null = null;
148
+ let remote: RemoteHost = DEFAULT_REMOTE;
149
+
150
+ for (let i = 0; i < args.length; i++) {
151
+ const arg = args[i];
152
+ if (arg === "-d") {
153
+ detached = true;
154
+ } else if (arg === "--name") {
155
+ const next = args[i + 1];
156
+ if (!next || next.startsWith("-")) {
157
+ console.error("Error: --name requires a value");
158
+ process.exit(1);
159
+ }
160
+ name = next;
161
+ i++;
162
+ } else if (arg === "--remote") {
163
+ const next = args[i + 1];
164
+ if (!next || !VALID_REMOTE_HOSTS.includes(next as RemoteHost)) {
165
+ console.error(
166
+ `Error: --remote requires one of: ${VALID_REMOTE_HOSTS.join(", ")}`,
167
+ );
168
+ process.exit(1);
169
+ }
170
+ remote = next as RemoteHost;
171
+ i++;
172
+ } else if (VALID_SPECIES.includes(arg as Species)) {
173
+ species = arg as Species;
174
+ } else {
175
+ console.error(
176
+ `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
177
+ );
178
+ process.exit(1);
179
+ }
180
+ }
181
+
182
+ return { species, detached, name, remote };
183
+ }
184
+
185
+ interface PollResult {
186
+ lastLine: string | null;
187
+ done: boolean;
188
+ failed: boolean;
189
+ }
190
+
191
+ async function pollInstance(
192
+ instanceName: string,
193
+ project: string,
194
+ zone: string,
195
+ ): Promise<PollResult> {
196
+ try {
197
+ const remoteCmd =
198
+ "L=$(tail -1 /var/log/startup-script.log 2>/dev/null || true); " +
199
+ "S=$(systemctl is-active google-startup-scripts.service 2>/dev/null || true); " +
200
+ "E=$(cat /var/log/startup-error 2>/dev/null || true); " +
201
+ 'printf "%s\\n===HATCH_SEP===\\n%s\\n===HATCH_ERR===\\n%s" "$L" "$S" "$E"';
202
+ const output = await execOutput("gcloud", [
203
+ "compute",
204
+ "ssh",
205
+ instanceName,
206
+ `--project=${project}`,
207
+ `--zone=${zone}`,
208
+ "--quiet",
209
+ "--ssh-flag=-o StrictHostKeyChecking=no",
210
+ "--ssh-flag=-o UserKnownHostsFile=/dev/null",
211
+ "--ssh-flag=-o ConnectTimeout=10",
212
+ "--ssh-flag=-o LogLevel=ERROR",
213
+ `--command=${remoteCmd}`,
214
+ ]);
215
+ const sepIdx = output.indexOf("===HATCH_SEP===");
216
+ if (sepIdx === -1) {
217
+ return { lastLine: output.trim() || null, done: false, failed: false };
218
+ }
219
+ const errIdx = output.indexOf("===HATCH_ERR===");
220
+ const lastLine = output.substring(0, sepIdx).trim() || null;
221
+ const statusEnd = errIdx === -1 ? undefined : errIdx;
222
+ const status = output.substring(sepIdx + "===HATCH_SEP===".length, statusEnd).trim();
223
+ const errorContent =
224
+ errIdx === -1 ? "" : output.substring(errIdx + "===HATCH_ERR===".length).trim();
225
+ const done = lastLine !== null && status !== "active" && status !== "activating";
226
+ const failed = errorContent.length > 0 || status === "failed";
227
+ return { lastLine, done, failed };
228
+ } catch {
229
+ return { lastLine: null, done: false, failed: false };
230
+ }
231
+ }
232
+
233
+ function formatElapsed(ms: number): string {
234
+ const secs = Math.floor(ms / 1000);
235
+ const m = Math.floor(secs / 60);
236
+ const s = secs % 60;
237
+ return m > 0 ? `${m}m ${s.toString().padStart(2, "0")}s` : `${s}s`;
238
+ }
239
+
240
+ function pickMessage(messages: string[], elapsedMs: number): string {
241
+ const idx = Math.floor(elapsedMs / 15000) % messages.length;
242
+ return messages[idx];
243
+ }
244
+
245
+ function getPhaseIcon(hasLogs: boolean, elapsedMs: number, species: Species): string {
246
+ if (!hasLogs) {
247
+ return elapsedMs < 30000 ? "🥚" : "🪺";
248
+ }
249
+ return elapsedMs < 120000 ? "🐣" : SPECIES_CONFIG[species].hatchedEmoji;
250
+ }
251
+
252
+ async function checkCurlFailure(
253
+ instanceName: string,
254
+ project: string,
255
+ zone: string,
256
+ ): Promise<boolean> {
257
+ try {
258
+ const output = await execOutput("gcloud", [
259
+ "compute",
260
+ "ssh",
261
+ instanceName,
262
+ `--project=${project}`,
263
+ `--zone=${zone}`,
264
+ "--quiet",
265
+ "--ssh-flag=-o StrictHostKeyChecking=no",
266
+ "--ssh-flag=-o UserKnownHostsFile=/dev/null",
267
+ "--ssh-flag=-o ConnectTimeout=10",
268
+ "--ssh-flag=-o LogLevel=ERROR",
269
+ `--command=test -s ${INSTALL_SCRIPT_REMOTE_PATH} && echo EXISTS || echo MISSING`,
270
+ ]);
271
+ return output.trim() === "MISSING";
272
+ } catch {
273
+ return false;
274
+ }
275
+ }
276
+
277
+ async function recoverFromCurlFailure(
278
+ instanceName: string,
279
+ project: string,
280
+ zone: string,
281
+ sshUser: string,
282
+ ): Promise<void> {
283
+ if (!existsSync(INSTALL_SCRIPT_PATH)) {
284
+ throw new Error(`Install script not found at ${INSTALL_SCRIPT_PATH}`);
285
+ }
286
+
287
+ console.log("📋 Uploading install script to instance...");
288
+ await exec("gcloud", [
289
+ "compute",
290
+ "scp",
291
+ INSTALL_SCRIPT_PATH,
292
+ `${instanceName}:${INSTALL_SCRIPT_REMOTE_PATH}`,
293
+ `--zone=${zone}`,
294
+ `--project=${project}`,
295
+ ]);
296
+
297
+ console.log("🔧 Running install script on instance...");
298
+ await exec("gcloud", [
299
+ "compute",
300
+ "ssh",
301
+ `${sshUser}@${instanceName}`,
302
+ `--zone=${zone}`,
303
+ `--project=${project}`,
304
+ `--command=source ${INSTALL_SCRIPT_REMOTE_PATH}`,
305
+ ]);
306
+ }
307
+
308
+ async function watchHatching(
309
+ instanceName: string,
310
+ project: string,
311
+ zone: string,
312
+ startTime: number,
313
+ species: Species,
314
+ ): Promise<boolean> {
315
+ let spinnerIdx = 0;
316
+ let lastLogLine: string | null = null;
317
+ let linesDrawn = 0;
318
+ let finished = false;
319
+ let failed = false;
320
+ let pollInFlight = false;
321
+ let nextPollAt = Date.now() + 15000;
322
+
323
+ function draw(): void {
324
+ if (linesDrawn > 0) {
325
+ process.stdout.write(`\x1b[${linesDrawn}A`);
326
+ }
327
+
328
+ const elapsed = Date.now() - startTime;
329
+
330
+ const hasLogs = lastLogLine !== null;
331
+ const icon = finished
332
+ ? failed
333
+ ? "💀"
334
+ : SPECIES_CONFIG[species].hatchedEmoji
335
+ : getPhaseIcon(hasLogs, elapsed, species);
336
+ const spinner = finished
337
+ ? failed
338
+ ? "✘"
339
+ : "✔"
340
+ : SPINNER_FRAMES[spinnerIdx % SPINNER_FRAMES.length];
341
+ const config = SPECIES_CONFIG[species];
342
+ const message = finished
343
+ ? failed
344
+ ? "❌ Startup script failed"
345
+ : "✨ Your assistant has hatched!"
346
+ : hasLogs
347
+ ? lastLogLine!.length > 68
348
+ ? lastLogLine!.substring(0, 65) + "..."
349
+ : lastLogLine!
350
+ : pickMessage(config.waitingMessages, elapsed);
351
+ spinnerIdx++;
352
+
353
+ const lines = ["", ` ${icon} ${spinner} ${message} ⏱ ${formatElapsed(elapsed)}`, ""];
354
+
355
+ for (const line of lines) {
356
+ process.stdout.write(`\x1b[K${line}\n`);
357
+ }
358
+ linesDrawn = lines.length;
359
+ }
360
+
361
+ async function poll(): Promise<void> {
362
+ if (pollInFlight || finished) return;
363
+ pollInFlight = true;
364
+ try {
365
+ const result = await pollInstance(instanceName, project, zone);
366
+ if (result.lastLine) {
367
+ lastLogLine = result.lastLine;
368
+ }
369
+ if (result.done) {
370
+ finished = true;
371
+ failed = result.failed;
372
+ }
373
+ } finally {
374
+ pollInFlight = false;
375
+ nextPollAt = Date.now() + 5000;
376
+ }
377
+ }
378
+
379
+ return new Promise<boolean>((resolve) => {
380
+ const interval = setInterval(() => {
381
+ if (finished) {
382
+ draw();
383
+ clearInterval(interval);
384
+ resolve(!failed);
385
+ return;
386
+ }
387
+
388
+ const elapsed = Date.now() - startTime;
389
+ if (elapsed >= HATCH_TIMEOUT_MS) {
390
+ clearInterval(interval);
391
+ console.log("");
392
+ console.log(` ⏰ Timed out after ${formatElapsed(elapsed)}. Instance is still running.`);
393
+ console.log(` Monitor with: vel logs ${instanceName}`);
394
+ console.log("");
395
+ resolve(true);
396
+ return;
397
+ }
398
+
399
+ if (Date.now() >= nextPollAt) {
400
+ poll();
401
+ }
402
+
403
+ draw();
404
+ }, 80);
405
+
406
+ process.on("SIGINT", () => {
407
+ clearInterval(interval);
408
+ console.log("");
409
+ console.log(` ⚠️ Detaching. Instance is still running.`);
410
+ console.log(` Monitor with: vel logs ${instanceName}`);
411
+ console.log("");
412
+ process.exit(0);
413
+ });
414
+ });
415
+ }
416
+
417
+ interface CloudCredentials {
418
+ provider: string;
419
+ projectId?: string;
420
+ serviceAccountKey?: string;
421
+ }
422
+
423
+ interface WorkspaceConfig {
424
+ cloudCredentials?: CloudCredentials;
425
+ }
426
+
427
+ async function activateGcpCredentialsFromConfig(): Promise<void> {
428
+ const configPath = join(homedir(), ".vellum", "workspace", "config.json");
429
+ let config: WorkspaceConfig;
430
+ try {
431
+ config = JSON.parse(readFileSync(configPath, "utf8")) as WorkspaceConfig;
432
+ } catch {
433
+ return;
434
+ }
435
+
436
+ const creds = config.cloudCredentials;
437
+ if (!creds || creds.provider !== "gcp" || !creds.serviceAccountKey || !creds.projectId) {
438
+ return;
439
+ }
440
+
441
+ const keyPath = join(tmpdir(), `vellum-sa-key-${Date.now()}.json`);
442
+ writeFileSync(keyPath, creds.serviceAccountKey);
443
+ try {
444
+ await exec("gcloud", [
445
+ "auth",
446
+ "activate-service-account",
447
+ `--key-file=${keyPath}`,
448
+ ]);
449
+ await exec("gcloud", ["config", "set", "project", creds.projectId]);
450
+ } finally {
451
+ try {
452
+ unlinkSync(keyPath);
453
+ } catch {}
454
+ }
455
+ }
456
+
457
+ async function hatchGcp(
458
+ species: Species,
459
+ detached: boolean,
460
+ name: string | null,
461
+ ): Promise<void> {
462
+ const startTime = Date.now();
463
+ try {
464
+ await activateGcpCredentialsFromConfig();
465
+ const project = process.env.GCP_PROJECT ?? (await getActiveProject());
466
+ let instanceName: string;
467
+
468
+ if (name) {
469
+ instanceName = name;
470
+ } else {
471
+ const suffix = generateRandomSuffix();
472
+ instanceName = `${species}-${suffix}`;
473
+ }
474
+
475
+ console.log(`🥚 Creating new assistant: ${instanceName}`);
476
+ console.log(` Species: ${species}`);
477
+ console.log(` Cloud: GCP`);
478
+ console.log(` Project: ${project}`);
479
+ console.log(` Zone: ${DEFAULT_ZONE}`);
480
+ console.log(` Machine type: ${MACHINE_TYPE}`);
481
+ console.log("");
482
+
483
+ if (name) {
484
+ if (await instanceExists(name, project, DEFAULT_ZONE)) {
485
+ console.error(
486
+ `Error: Instance name '${name}' is already taken. Please choose a different name.`,
487
+ );
488
+ process.exit(1);
489
+ }
490
+ } else {
491
+ while (await instanceExists(instanceName, project, DEFAULT_ZONE)) {
492
+ console.log(`⚠️ Instance name ${instanceName} already exists, generating a new name...`);
493
+ const suffix = generateRandomSuffix();
494
+ instanceName = `${species}-${suffix}`;
495
+ }
496
+ }
497
+
498
+ const sshUser = userInfo().username;
499
+ const bearerToken = randomBytes(32).toString("hex");
500
+ const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
501
+ if (!anthropicApiKey) {
502
+ console.error("Error: ANTHROPIC_API_KEY environment variable is not set.");
503
+ process.exit(1);
504
+ }
505
+ const startupScript = buildStartupScript(species, bearerToken, sshUser, anthropicApiKey);
506
+ const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
507
+ writeFileSync(startupScriptPath, startupScript);
508
+
509
+ console.log("🔨 Creating instance with startup script...");
510
+ try {
511
+ await exec("gcloud", [
512
+ "compute",
513
+ "instances",
514
+ "create",
515
+ instanceName,
516
+ `--project=${project}`,
517
+ `--zone=${DEFAULT_ZONE}`,
518
+ `--machine-type=${MACHINE_TYPE}`,
519
+ "--image-family=debian-11",
520
+ "--image-project=debian-cloud",
521
+ "--boot-disk-size=50GB",
522
+ "--boot-disk-type=pd-standard",
523
+ `--metadata-from-file=startup-script=${startupScriptPath}`,
524
+ `--labels=species=${species},vellum-assistant=true`,
525
+ "--tags=vellum-assistant",
526
+ ]);
527
+ } finally {
528
+ try {
529
+ unlinkSync(startupScriptPath);
530
+ } catch {}
531
+ }
532
+
533
+ console.log("🔒 Syncing firewall rules...");
534
+ await syncFirewallRules(DESIRED_FIREWALL_RULES, project, FIREWALL_TAG);
535
+
536
+ console.log(`✅ Instance ${instanceName} created successfully\n`);
537
+
538
+ let externalIp: string | null = null;
539
+ try {
540
+ const ipOutput = await execOutput("gcloud", [
541
+ "compute",
542
+ "instances",
543
+ "describe",
544
+ instanceName,
545
+ `--project=${project}`,
546
+ `--zone=${DEFAULT_ZONE}`,
547
+ "--format=get(networkInterfaces[0].accessConfigs[0].natIP)",
548
+ ]);
549
+ externalIp = ipOutput.trim() || null;
550
+ } catch {
551
+ console.log("⚠️ Could not retrieve external IP yet (instance may still be starting)");
552
+ }
553
+
554
+ const runtimeUrl = externalIp
555
+ ? `http://${externalIp}:${GATEWAY_PORT}`
556
+ : `http://${instanceName}:${GATEWAY_PORT}`;
557
+ const entryFilePath = process.env.VELLUM_HATCH_ENTRY_FILE;
558
+ if (entryFilePath) {
559
+ writeFileSync(
560
+ entryFilePath,
561
+ JSON.stringify({
562
+ assistantId: instanceName,
563
+ runtimeUrl,
564
+ bearerToken,
565
+ project,
566
+ zone: DEFAULT_ZONE,
567
+ species,
568
+ sshUser,
569
+ hatchedAt: new Date().toISOString(),
570
+ }),
571
+ );
572
+ }
573
+
574
+ if (detached) {
575
+ console.log("🚀 Startup script is running on the instance...");
576
+ console.log("");
577
+ console.log("✅ Assistant is hatching!\n");
578
+ console.log("Instance details:");
579
+ console.log(` Name: ${instanceName}`);
580
+ console.log(` Project: ${project}`);
581
+ console.log(` Zone: ${DEFAULT_ZONE}`);
582
+ if (externalIp) {
583
+ console.log(` External IP: ${externalIp}`);
584
+ }
585
+ console.log("");
586
+ } else {
587
+ console.log(" Press Ctrl+C to detach (instance will keep running)");
588
+ console.log("");
589
+
590
+ const success = await watchHatching(
591
+ instanceName,
592
+ project,
593
+ DEFAULT_ZONE,
594
+ startTime,
595
+ species,
596
+ );
597
+
598
+ if (!success) {
599
+ if (
600
+ species === "vellum" &&
601
+ (await checkCurlFailure(instanceName, project, DEFAULT_ZONE))
602
+ ) {
603
+ console.log("");
604
+ console.log("🔄 Detected install script curl failure, attempting recovery...");
605
+ await recoverFromCurlFailure(instanceName, project, DEFAULT_ZONE, sshUser);
606
+ console.log("✅ Recovery successful!");
607
+ } else {
608
+ console.log("");
609
+ process.exit(1);
610
+ }
611
+ }
612
+
613
+ console.log("Instance details:");
614
+ console.log(` Name: ${instanceName}`);
615
+ console.log(` Project: ${project}`);
616
+ console.log(` Zone: ${DEFAULT_ZONE}`);
617
+ if (externalIp) {
618
+ console.log(` External IP: ${externalIp}`);
619
+ }
620
+ }
621
+ } catch (error) {
622
+ console.error("❌ Error:", error instanceof Error ? error.message : error);
623
+ process.exit(1);
624
+ }
625
+ }
626
+
627
+ function buildSshArgs(host: string): string[] {
628
+ return [
629
+ host,
630
+ "-o", "StrictHostKeyChecking=no",
631
+ "-o", "UserKnownHostsFile=/dev/null",
632
+ "-o", "ConnectTimeout=10",
633
+ "-o", "LogLevel=ERROR",
634
+ ];
635
+ }
636
+
637
+ function extractHostname(host: string): string {
638
+ return host.includes("@") ? host.split("@")[1] : host;
639
+ }
640
+
641
+ async function hatchCustom(
642
+ species: Species,
643
+ detached: boolean,
644
+ name: string | null,
645
+ ): Promise<void> {
646
+ const host = process.env.VELLUM_CUSTOM_HOST;
647
+ if (!host) {
648
+ console.error("Error: VELLUM_CUSTOM_HOST environment variable is required when using --remote custom (e.g., user@hostname)");
649
+ process.exit(1);
650
+ }
651
+
652
+ try {
653
+ const hostname = extractHostname(host);
654
+ const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
655
+
656
+ console.log(`🥚 Creating new assistant: ${instanceName}`);
657
+ console.log(` Species: ${species}`);
658
+ console.log(` Cloud: Custom`);
659
+ console.log(` Host: ${host}`);
660
+ console.log("");
661
+
662
+ const sshUser = host.includes("@") ? host.split("@")[0] : userInfo().username;
663
+ const bearerToken = randomBytes(32).toString("hex");
664
+ const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
665
+ if (!anthropicApiKey) {
666
+ console.error("Error: ANTHROPIC_API_KEY environment variable is not set.");
667
+ process.exit(1);
668
+ }
669
+
670
+ const startupScript = buildStartupScript(species, bearerToken, sshUser, anthropicApiKey);
671
+ const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
672
+ writeFileSync(startupScriptPath, startupScript);
673
+
674
+ try {
675
+ console.log("📋 Uploading install script to instance...");
676
+ await exec("scp", [
677
+ "-o", "StrictHostKeyChecking=no",
678
+ "-o", "UserKnownHostsFile=/dev/null",
679
+ "-o", "LogLevel=ERROR",
680
+ INSTALL_SCRIPT_PATH,
681
+ `${host}:${INSTALL_SCRIPT_REMOTE_PATH}`,
682
+ ]);
683
+
684
+ console.log("📋 Uploading startup script to instance...");
685
+ const remoteStartupPath = `/tmp/${instanceName}-startup.sh`;
686
+ await exec("scp", [
687
+ "-o", "StrictHostKeyChecking=no",
688
+ "-o", "UserKnownHostsFile=/dev/null",
689
+ "-o", "LogLevel=ERROR",
690
+ startupScriptPath,
691
+ `${host}:${remoteStartupPath}`,
692
+ ]);
693
+
694
+ console.log("🔨 Running startup script on instance...");
695
+ await exec("ssh", [
696
+ ...buildSshArgs(host),
697
+ `chmod +x ${remoteStartupPath} ${INSTALL_SCRIPT_REMOTE_PATH} && bash ${remoteStartupPath}`,
698
+ ]);
699
+ } finally {
700
+ try {
701
+ unlinkSync(startupScriptPath);
702
+ } catch {}
703
+ }
704
+
705
+ const runtimeUrl = `http://${hostname}:${GATEWAY_PORT}`;
706
+ const entryFilePath = process.env.VELLUM_HATCH_ENTRY_FILE;
707
+ if (entryFilePath) {
708
+ writeFileSync(
709
+ entryFilePath,
710
+ JSON.stringify({
711
+ assistantId: instanceName,
712
+ runtimeUrl,
713
+ bearerToken,
714
+ species,
715
+ sshUser,
716
+ hatchedAt: new Date().toISOString(),
717
+ }),
718
+ );
719
+ }
720
+
721
+ if (detached) {
722
+ console.log("");
723
+ console.log("✅ Assistant is hatching!\n");
724
+ } else {
725
+ console.log("");
726
+ console.log("✅ Assistant has been set up!");
727
+ }
728
+ console.log("Instance details:");
729
+ console.log(` Name: ${instanceName}`);
730
+ console.log(` Host: ${host}`);
731
+ console.log(` Runtime URL: ${runtimeUrl}`);
732
+ console.log("");
733
+ } catch (error) {
734
+ console.error("❌ Error:", error instanceof Error ? error.message : error);
735
+ process.exit(1);
736
+ }
737
+ }
738
+
739
+ async function hatchLocal(species: Species, name: string | null): Promise<void> {
740
+ const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
741
+
742
+ console.log(`🥚 Hatching local assistant: ${instanceName}`);
743
+ console.log(` Species: ${species}`);
744
+ console.log("");
745
+
746
+ console.log("🔨 Starting local daemon...");
747
+ const child = spawn("bunx", ["vellum", "daemon", "start"], {
748
+ stdio: "inherit",
749
+ env: { ...process.env },
750
+ });
751
+
752
+ await new Promise<void>((resolve, reject) => {
753
+ child.on("close", (code) => {
754
+ if (code === 0) {
755
+ resolve();
756
+ } else {
757
+ reject(new Error(`Daemon start exited with code ${code}`));
758
+ }
759
+ });
760
+ child.on("error", reject);
761
+ });
762
+
763
+ const runtimeUrl = `http://localhost:${GATEWAY_PORT}`;
764
+ const entryFilePath = process.env.VELLUM_HATCH_ENTRY_FILE;
765
+ if (entryFilePath) {
766
+ writeFileSync(
767
+ entryFilePath,
768
+ JSON.stringify({
769
+ assistantId: instanceName,
770
+ runtimeUrl,
771
+ species,
772
+ hatchedAt: new Date().toISOString(),
773
+ }),
774
+ );
775
+ }
776
+
777
+ console.log("");
778
+ console.log(`✅ Local assistant hatched!`);
779
+ console.log("");
780
+ console.log("Instance details:");
781
+ console.log(` Name: ${instanceName}`);
782
+ console.log(` Runtime: ${runtimeUrl}`);
783
+ console.log("");
784
+ }
785
+
786
+ export async function hatch(): Promise<void> {
787
+ const { species, detached, name, remote } = parseArgs();
788
+
789
+ if (remote === "local") {
790
+ await hatchLocal(species, name);
791
+ return;
792
+ }
793
+
794
+ if (remote === "gcp") {
795
+ await hatchGcp(species, detached, name);
796
+ return;
797
+ }
798
+
799
+ if (remote === "custom") {
800
+ await hatchCustom(species, detached, name);
801
+ return;
802
+ }
803
+
804
+ console.error(`Error: Remote host '${remote}' is not yet supported.`);
805
+ process.exit(1);
806
+ }