@vellumai/cli 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,117 +441,6 @@ async function hatchCustom(
780
441
  }
781
442
  }
782
443
 
783
- function isGatewaySourceDir(dir: string): boolean {
784
- return existsSync(join(dir, "package.json")) && existsSync(join(dir, "src", "index.ts"));
785
- }
786
-
787
- function findGatewaySourceFromCwd(): string | undefined {
788
- let current = process.cwd();
789
- while (true) {
790
- if (isGatewaySourceDir(current)) {
791
- return current;
792
- }
793
- const nestedCandidate = join(current, "gateway");
794
- if (isGatewaySourceDir(nestedCandidate)) {
795
- return nestedCandidate;
796
- }
797
- const parent = dirname(current);
798
- if (parent === current) {
799
- return undefined;
800
- }
801
- current = parent;
802
- }
803
- }
804
-
805
- function resolveGatewayDir(): string {
806
- const override = process.env.VELLUM_GATEWAY_DIR?.trim();
807
- if (override) {
808
- if (!isGatewaySourceDir(override)) {
809
- throw new Error(
810
- `VELLUM_GATEWAY_DIR is set to "${override}", but it is not a valid gateway source directory.`,
811
- );
812
- }
813
- return override;
814
- }
815
-
816
- const sourceDir = join(import.meta.dir, "..", "..", "..", "gateway");
817
- if (isGatewaySourceDir(sourceDir)) {
818
- return sourceDir;
819
- }
820
-
821
- const cwdSourceDir = findGatewaySourceFromCwd();
822
- if (cwdSourceDir) {
823
- return cwdSourceDir;
824
- }
825
-
826
- try {
827
- const pkgPath = _require.resolve("@vellumai/vellum-gateway/package.json");
828
- return dirname(pkgPath);
829
- } catch {
830
- throw new Error(
831
- "Gateway not found. Ensure @vellumai/vellum-gateway is installed, run from the source tree, or set VELLUM_GATEWAY_DIR.",
832
- );
833
- }
834
- }
835
-
836
- function normalizeIngressUrl(value: unknown): string | undefined {
837
- if (typeof value !== "string") return undefined;
838
- const normalized = value.trim().replace(/\/+$/, "");
839
- return normalized || undefined;
840
- }
841
-
842
- function readWorkspaceIngressPublicBaseUrl(): string | undefined {
843
- const baseDataDir = process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir());
844
- const workspaceConfigPath = join(baseDataDir, ".vellum", "workspace", "config.json");
845
- try {
846
- const raw = JSON.parse(readFileSync(workspaceConfigPath, "utf-8")) as Record<string, unknown>;
847
- const ingress = raw.ingress as Record<string, unknown> | undefined;
848
- return normalizeIngressUrl(ingress?.publicBaseUrl);
849
- } catch {
850
- return undefined;
851
- }
852
- }
853
-
854
- async function discoverPublicUrl(): Promise<string | undefined> {
855
- const cloud = process.env.VELLUM_CLOUD;
856
- if (!cloud || cloud === "local") {
857
- return `http://localhost:${GATEWAY_PORT}`;
858
- }
859
-
860
- let externalIp: string | undefined;
861
- try {
862
- if (cloud === "gcp") {
863
- const resp = await fetch(
864
- "http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
865
- { headers: { "Metadata-Flavor": "Google" } },
866
- );
867
- if (resp.ok) externalIp = (await resp.text()).trim();
868
- } else if (cloud === "aws") {
869
- // Use IMDSv2 (token-based) for compatibility with HttpTokens=required
870
- const tokenResp = await fetch(
871
- "http://169.254.169.254/latest/api/token",
872
- { method: "PUT", headers: { "X-aws-ec2-metadata-token-ttl-seconds": "30" } },
873
- );
874
- if (tokenResp.ok) {
875
- const token = await tokenResp.text();
876
- const ipResp = await fetch(
877
- "http://169.254.169.254/latest/meta-data/public-ipv4",
878
- { headers: { "X-aws-ec2-metadata-token": token } },
879
- );
880
- if (ipResp.ok) externalIp = (await ipResp.text()).trim();
881
- }
882
- }
883
- } catch {
884
- // metadata service not reachable
885
- }
886
-
887
- if (externalIp) {
888
- console.log(` Discovered external IP: ${externalIp}`);
889
- return `http://${externalIp}:${GATEWAY_PORT}`;
890
- }
891
- return undefined;
892
- }
893
-
894
444
  async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false): Promise<void> {
895
445
  const instanceName =
896
446
  name ?? process.env.VELLUM_ASSISTANT_NAME ?? `${species}-${generateRandomSuffix()}`;
@@ -899,152 +449,7 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo
899
449
  console.log(` Species: ${species}`);
900
450
  console.log("");
901
451
 
902
- 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
- }
452
+ await startLocalDaemon();
1048
453
 
1049
454
  // The desktop app communicates with the daemon directly via Unix socket,
1050
455
  // so the HTTP gateway is only needed for non-desktop (CLI) usage.
@@ -1054,40 +459,7 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo
1054
459
  // No gateway needed — the macOS app uses DaemonClient over the Unix socket.
1055
460
  runtimeUrl = "local";
1056
461
  } else {
1057
- const publicUrl = await discoverPublicUrl();
1058
- if (publicUrl) {
1059
- console.log(` Public URL: ${publicUrl}`);
1060
- }
1061
-
1062
- console.log("🌐 Starting gateway...");
1063
- const gatewayDir = resolveGatewayDir();
1064
- const gatewayEnv: Record<string, string> = {
1065
- ...process.env as Record<string, string>,
1066
- GATEWAY_RUNTIME_PROXY_ENABLED: "true",
1067
- GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false",
1068
- };
1069
- const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl();
1070
- const ingressPublicBaseUrl =
1071
- workspaceIngressPublicBaseUrl
1072
- ?? normalizeIngressUrl(process.env.INGRESS_PUBLIC_BASE_URL);
1073
- if (ingressPublicBaseUrl) {
1074
- gatewayEnv.INGRESS_PUBLIC_BASE_URL = ingressPublicBaseUrl;
1075
- console.log(` Ingress URL: ${ingressPublicBaseUrl}`);
1076
- if (!workspaceIngressPublicBaseUrl) {
1077
- console.log(" (using INGRESS_PUBLIC_BASE_URL env fallback)");
1078
- }
1079
- }
1080
- if (publicUrl) gatewayEnv.GATEWAY_PUBLIC_URL = publicUrl;
1081
-
1082
- const gateway = spawn("bun", ["run", "src/index.ts"], {
1083
- cwd: gatewayDir,
1084
- detached: true,
1085
- stdio: "ignore",
1086
- env: gatewayEnv,
1087
- });
1088
- gateway.unref();
1089
- console.log("✅ Gateway started\n");
1090
- runtimeUrl = publicUrl || `http://localhost:${GATEWAY_PORT}`;
462
+ runtimeUrl = await startGateway();
1091
463
  }
1092
464
 
1093
465
  const baseDataDir = join(process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? userInfo().homedir), ".vellum");
@@ -1135,7 +507,7 @@ export async function hatch(): Promise<void> {
1135
507
  }
1136
508
 
1137
509
  if (remote === "gcp") {
1138
- await hatchGcp(species, detached, name);
510
+ await hatchGcp(species, detached, name, buildStartupScript, watchHatching);
1139
511
  return;
1140
512
  }
1141
513