@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.
@@ -1,35 +1,30 @@
1
- import { spawn } from "child_process";
2
1
  import { randomBytes } from "crypto";
3
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2
+ import { existsSync, unlinkSync, writeFileSync } from "fs";
4
3
  import { createRequire } from "module";
5
- import { tmpdir, userInfo, homedir } from "os";
6
- import { dirname, join } from "path";
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 type { FirewallRuleSpec } from "../lib/gcp";
21
- import { fetchAndDisplayStartupLogs, getActiveProject, instanceExists, syncFirewallRules } from "../lib/gcp";
22
- import { buildInterfacesSeed } from "../lib/interfaces-seed";
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, execOutput } from "../lib/step-runner";
22
+ import { exec } from "../lib/step-runner";
25
23
 
26
- const _require = createRequire(import.meta.url);
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 DESIRED_FIREWALL_RULES: FirewallRuleSpec[] = [
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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
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
- async function hatchGcp(
329
+ function watchHatchingDesktop(
330
+ pollFn: () => Promise<PollResult>,
331
+ instanceName: string,
332
+ startTime: number,
474
333
  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;
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
- if (name) {
485
- instanceName = name;
486
- } else {
487
- const suffix = generateRandomSuffix();
488
- instanceName = `${species}-${suffix}`;
489
- }
341
+ desktopLog("Waiting for instance to start...");
490
342
 
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("");
343
+ const interval = setInterval(async () => {
344
+ const elapsed = Date.now() - startTime;
504
345
 
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}`;
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
- 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);
354
+ if (Date.now() < nextPollAt || pollInFlight) return;
537
355
 
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 {
356
+ pollInFlight = true;
559
357
  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
- }
358
+ const result = await pollFn();
586
359
 
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
- );
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
- console.log("📋 Startup error:");
630
- console.log(` ${result.errorContent}`);
631
- console.log("");
366
+ lastErrorContent = result.errorContent;
632
367
  }
633
368
 
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);
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
- 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
- }
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