@vellumai/cli 0.1.10 → 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.
@@ -1,35 +1,31 @@
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";
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, execOutput } from "../lib/step-runner";
23
+ import { exec } from "../lib/step-runner";
25
24
 
26
- const _require = createRequire(import.meta.url);
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 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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
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,310 +441,6 @@ async function hatchCustom(
780
441
  }
781
442
  }
782
443
 
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
444
  async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false): Promise<void> {
1088
445
  const instanceName =
1089
446
  name ?? process.env.VELLUM_ASSISTANT_NAME ?? `${species}-${generateRandomSuffix()}`;
@@ -1150,7 +507,7 @@ export async function hatch(): Promise<void> {
1150
507
  }
1151
508
 
1152
509
  if (remote === "gcp") {
1153
- await hatchGcp(species, detached, name);
510
+ await hatchGcp(species, detached, name, buildStartupScript, watchHatching);
1154
511
  return;
1155
512
  }
1156
513