@vellumai/cli 0.5.14 → 0.5.16

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,34 +1,12 @@
1
1
  import { randomBytes } from "crypto";
2
- import {
3
- appendFileSync,
4
- existsSync,
5
- lstatSync,
6
- mkdirSync,
7
- readFileSync,
8
- readlinkSync,
9
- rmSync,
10
- symlinkSync,
11
- unlinkSync,
12
- writeFileSync,
13
- } from "fs";
14
- import { homedir } from "os";
15
- import { join } from "path";
16
2
 
17
3
  // Direct import — bun embeds this at compile time so it works in compiled binaries.
18
4
  import cliPkg from "../../package.json";
19
5
 
20
6
  import { buildOpenclawStartupScript } from "../adapters/openclaw";
21
7
  import {
22
- allocateLocalResources,
23
- findAssistantByName,
24
- loadAllAssistants,
25
8
  saveAssistantEntry,
26
9
  setActiveAssistant,
27
- syncConfigToLockfile,
28
- } from "../lib/assistant-config";
29
- import type {
30
- AssistantEntry,
31
- LocalInstanceResources,
32
10
  } from "../lib/assistant-config";
33
11
  import { hatchAws } from "../lib/aws";
34
12
  import {
@@ -37,30 +15,17 @@ import {
37
15
  VALID_SPECIES,
38
16
  } from "../lib/constants";
39
17
  import type { RemoteHost, Species } from "../lib/constants";
18
+ import { buildNestedConfig } from "../lib/config-utils";
40
19
  import { hatchDocker } from "../lib/docker";
41
20
  import { hatchGcp } from "../lib/gcp";
42
21
  import type { PollResult, WatchHatchingResult } from "../lib/gcp";
43
- import { buildNestedConfig, writeInitialConfig } from "../lib/config-utils";
44
- import {
45
- generateLocalSigningKey,
46
- startLocalDaemon,
47
- startGateway,
48
- stopLocalProcesses,
49
- } from "../lib/local";
50
- import { maybeStartNgrokTunnel } from "../lib/ngrok";
22
+ import { hatchLocal } from "../lib/hatch-local";
51
23
  import {
52
24
  getPlatformUrl,
53
25
  hatchAssistant,
54
26
  readPlatformToken,
55
27
  } from "../lib/platform-client";
56
- import { httpHealthCheck } from "../lib/http-client";
57
- import { detectOrphanedProcesses } from "../lib/orphan-detection";
58
- import { isProcessAlive, stopProcess } from "../lib/process";
59
- import { generateInstanceName } from "../lib/random-name";
60
28
  import { validateAssistantName } from "../lib/retire-archive";
61
- import { leaseGuardianToken } from "../lib/guardian-token";
62
- import { archiveLogFile, resetLogFile } from "../lib/xdg-log";
63
- import { emitProgress } from "../lib/desktop-progress.js";
64
29
 
65
30
  export type { PollResult, WatchHatchingResult } from "../lib/gcp";
66
31
 
@@ -541,356 +506,7 @@ function watchHatchingDesktop(
541
506
  });
542
507
  }
543
508
 
544
- /**
545
- * Attempts to place a symlink at the given path pointing to cliBinary.
546
- * Returns true if the symlink was created (or already correct), false on failure.
547
- */
548
- function trySymlink(cliBinary: string, symlinkPath: string): boolean {
549
- try {
550
- // Use lstatSync (not existsSync) to detect dangling symlinks —
551
- // existsSync follows symlinks and returns false for broken links.
552
- try {
553
- const stats = lstatSync(symlinkPath);
554
- if (!stats.isSymbolicLink()) {
555
- // Real file — don't overwrite (developer's local install)
556
- return false;
557
- }
558
- // Already a symlink — skip if it already points to our binary
559
- const dest = readlinkSync(symlinkPath);
560
- if (dest === cliBinary) return true;
561
- // Stale or dangling symlink — remove before creating new one
562
- unlinkSync(symlinkPath);
563
- } catch (e) {
564
- if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") return false;
565
- // Path doesn't exist — proceed to create symlink
566
- }
567
-
568
- const dir = join(symlinkPath, "..");
569
- if (!existsSync(dir)) {
570
- mkdirSync(dir, { recursive: true });
571
- }
572
- symlinkSync(cliBinary, symlinkPath);
573
- return true;
574
- } catch {
575
- return false;
576
- }
577
- }
578
-
579
- /**
580
- * Ensures ~/.local/bin is present in the user's shell profile so that
581
- * symlinks placed there are on PATH in new terminal sessions.
582
- */
583
- function ensureLocalBinInShellProfile(localBinDir: string): void {
584
- const shell = process.env.SHELL ?? "";
585
- const home = homedir();
586
- // Determine the appropriate shell profile to modify
587
- const profilePath = shell.endsWith("/zsh")
588
- ? join(home, ".zshrc")
589
- : shell.endsWith("/bash")
590
- ? join(home, ".bash_profile")
591
- : null;
592
- if (!profilePath) return;
593
-
594
- try {
595
- const contents = existsSync(profilePath)
596
- ? readFileSync(profilePath, "utf-8")
597
- : "";
598
- // Check if ~/.local/bin is already referenced in PATH exports
599
- if (contents.includes(localBinDir)) return;
600
- const line = `\nexport PATH="${localBinDir}:\$PATH"\n`;
601
- appendFileSync(profilePath, line);
602
- console.log(` Added ${localBinDir} to ${profilePath}`);
603
- } catch {
604
- // Not critical — user can add it manually
605
- }
606
- }
607
-
608
- function installCLISymlink(): void {
609
- const cliBinary = process.execPath;
610
- if (!cliBinary || !existsSync(cliBinary)) return;
611
-
612
- // Preferred location — works on most Macs where /usr/local/bin exists
613
- const preferredPath = "/usr/local/bin/vellum";
614
- if (trySymlink(cliBinary, preferredPath)) {
615
- console.log(` Symlinked ${preferredPath} → ${cliBinary}`);
616
- return;
617
- }
618
-
619
- // Fallback — use ~/.local/bin which is user-writable and doesn't need root.
620
- // On some Macs /usr/local doesn't exist and creating it requires admin privileges.
621
- const localBinDir = join(homedir(), ".local", "bin");
622
- const fallbackPath = join(localBinDir, "vellum");
623
- if (trySymlink(cliBinary, fallbackPath)) {
624
- console.log(` Symlinked ${fallbackPath} → ${cliBinary}`);
625
- ensureLocalBinInShellProfile(localBinDir);
626
- return;
627
- }
628
-
629
- console.log(
630
- ` ⚠ Could not create symlink for vellum CLI (tried ${preferredPath} and ${fallbackPath})`,
631
- );
632
- }
633
-
634
- async function hatchLocal(
635
- species: Species,
636
- name: string | null,
637
- restart: boolean = false,
638
- watch: boolean = false,
639
- keepAlive: boolean = false,
640
- configValues: Record<string, string> = {},
641
- ): Promise<void> {
642
- if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
643
- console.error(
644
- "Error: Cannot restart without a known assistant ID. Provide --name or ensure VELLUM_ASSISTANT_NAME is set.",
645
- );
646
- process.exit(1);
647
- }
648
-
649
- const instanceName = generateInstanceName(
650
- species,
651
- name ?? process.env.VELLUM_ASSISTANT_NAME,
652
- );
653
-
654
- emitProgress(1, 7, "Preparing workspace...");
655
-
656
- // Clean up stale local state: if daemon/gateway processes are running but
657
- // the lock file has no entries AND the daemon is not healthy, stop them
658
- // before starting fresh. A healthy daemon should be reused, not killed —
659
- // it may have been started intentionally via `vellum wake`.
660
- const vellumDir = join(homedir(), ".vellum");
661
- const existingAssistants = loadAllAssistants();
662
- const localAssistants = existingAssistants.filter((a) => a.cloud === "local");
663
- if (localAssistants.length === 0) {
664
- const daemonPid = isProcessAlive(join(vellumDir, "vellum.pid"));
665
- const gatewayPid = isProcessAlive(join(vellumDir, "gateway.pid"));
666
- if (daemonPid.alive || gatewayPid.alive) {
667
- // Check if the daemon is actually healthy before killing it.
668
- // Default port 7821 is used when there's no lockfile entry.
669
- const defaultPort = parseInt(process.env.RUNTIME_HTTP_PORT || "7821", 10);
670
- const healthy = await httpHealthCheck(defaultPort);
671
- if (!healthy) {
672
- console.log(
673
- "🧹 Cleaning up stale local processes (no lock file entry)...\n",
674
- );
675
- await stopLocalProcesses();
676
- }
677
- }
678
- }
679
-
680
- // On desktop, scan the process table for orphaned vellum processes that
681
- // are not tracked by any PID file or lock file entry and kill them before
682
- // starting new ones. This prevents resource leaks when the desktop app
683
- // crashes or is force-quit without a clean shutdown.
684
- //
685
- // Skip orphan cleanup if the daemon is already healthy on the expected port
686
- // — those processes are intentional (e.g. started via `vellum wake`) and
687
- // startLocalDaemon() will reuse them.
688
- if (IS_DESKTOP) {
689
- const existingResources = findAssistantByName(instanceName);
690
- const expectedPort =
691
- existingResources?.cloud === "local" && existingResources.resources
692
- ? existingResources.resources.daemonPort
693
- : undefined;
694
- const daemonAlreadyHealthy = expectedPort
695
- ? await httpHealthCheck(expectedPort)
696
- : false;
697
-
698
- if (!daemonAlreadyHealthy) {
699
- const orphans = await detectOrphanedProcesses();
700
- if (orphans.length > 0) {
701
- desktopLog(
702
- `🧹 Found ${orphans.length} orphaned process${orphans.length === 1 ? "" : "es"} — cleaning up...`,
703
- );
704
- for (const orphan of orphans) {
705
- await stopProcess(
706
- parseInt(orphan.pid, 10),
707
- `${orphan.name} (PID ${orphan.pid})`,
708
- );
709
- }
710
- }
711
- }
712
- }
713
-
714
- emitProgress(2, 7, "Allocating resources...");
715
-
716
- // Reuse existing resources if re-hatching with --name that matches a known
717
- // local assistant, otherwise allocate fresh per-instance ports and directories.
718
- let resources: LocalInstanceResources;
719
- const existingEntry = findAssistantByName(instanceName);
720
- if (existingEntry?.cloud === "local" && existingEntry.resources) {
721
- resources = existingEntry.resources;
722
- } else {
723
- resources = await allocateLocalResources(instanceName);
724
- }
725
-
726
- // Clean up stale workspace data: if the workspace directory already exists for
727
- // this instance but no local lockfile entry owns it, a previous retire failed
728
- // to archive it (or a managed-only retire left local data behind). Remove the
729
- // workspace subtree so the new assistant starts fresh — but preserve the rest
730
- // of .vellum (e.g. protected/, credentials) which may be shared.
731
- if (
732
- !existingEntry ||
733
- (existingEntry.cloud != null && existingEntry.cloud !== "local")
734
- ) {
735
- const instanceWorkspaceDir = join(
736
- resources.instanceDir,
737
- ".vellum",
738
- "workspace",
739
- );
740
- if (existsSync(instanceWorkspaceDir)) {
741
- const ownedByOther = loadAllAssistants().some((a) => {
742
- if ((a.cloud != null && a.cloud !== "local") || !a.resources)
743
- return false;
744
- return (
745
- join(a.resources.instanceDir, ".vellum", "workspace") ===
746
- instanceWorkspaceDir
747
- );
748
- });
749
- if (!ownedByOther) {
750
- console.log(
751
- `🧹 Removing stale workspace at ${instanceWorkspaceDir} (not owned by any assistant)...\n`,
752
- );
753
- rmSync(instanceWorkspaceDir, { recursive: true, force: true });
754
- }
755
- }
756
- }
757
-
758
- const logsDir = join(
759
- resources.instanceDir,
760
- ".vellum",
761
- "workspace",
762
- "data",
763
- "logs",
764
- );
765
- archiveLogFile("hatch.log", logsDir);
766
- resetLogFile("hatch.log");
767
-
768
- console.log(`🥚 Hatching local assistant: ${instanceName}`);
769
- console.log(` Species: ${species}`);
770
- console.log("");
771
-
772
- if (!process.env.APP_VERSION) {
773
- process.env.APP_VERSION = cliPkg.version;
774
- }
775
-
776
- emitProgress(3, 7, "Writing configuration...");
777
- const defaultWorkspaceConfigPath = writeInitialConfig(configValues);
778
-
779
- emitProgress(4, 7, "Starting assistant...");
780
- const signingKey = generateLocalSigningKey();
781
- await startLocalDaemon(watch, resources, {
782
- defaultWorkspaceConfigPath,
783
- signingKey,
784
- });
785
-
786
- emitProgress(5, 7, "Starting gateway...");
787
- let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
788
- try {
789
- runtimeUrl = await startGateway(watch, resources, { signingKey });
790
- } catch (error) {
791
- // Gateway failed — stop the daemon we just started so we don't leave
792
- // orphaned processes with no lock file entry.
793
- console.error(
794
- `\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
795
- );
796
- await stopLocalProcesses(resources);
797
- throw error;
798
- }
799
-
800
- // Lease a guardian token so the desktop app can import it on first launch
801
- // instead of hitting /v1/guardian/init itself.
802
- emitProgress(6, 7, "Securing connection...");
803
- try {
804
- await leaseGuardianToken(runtimeUrl, instanceName);
805
- } catch (err) {
806
- console.error(`⚠️ Guardian token lease failed: ${err}`);
807
- }
808
-
809
- // Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
810
- // Set BASE_DATA_DIR so ngrok reads the correct instance config.
811
- const prevBaseDataDir = process.env.BASE_DATA_DIR;
812
- process.env.BASE_DATA_DIR = resources.instanceDir;
813
- const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
814
- if (ngrokChild?.pid) {
815
- const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
816
- writeFileSync(ngrokPidFile, String(ngrokChild.pid));
817
- }
818
- if (prevBaseDataDir !== undefined) {
819
- process.env.BASE_DATA_DIR = prevBaseDataDir;
820
- } else {
821
- delete process.env.BASE_DATA_DIR;
822
- }
823
-
824
- const localEntry: AssistantEntry = {
825
- assistantId: instanceName,
826
- runtimeUrl,
827
- localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
828
- cloud: "local",
829
- species,
830
- hatchedAt: new Date().toISOString(),
831
- serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
832
- resources: { ...resources, signingKey },
833
- };
834
- emitProgress(7, 7, "Saving configuration...");
835
- if (!restart) {
836
- saveAssistantEntry(localEntry);
837
- setActiveAssistant(instanceName);
838
- syncConfigToLockfile();
839
-
840
- if (process.env.VELLUM_DESKTOP_APP) {
841
- installCLISymlink();
842
- }
843
-
844
- console.log("");
845
- console.log(`✅ Local assistant hatched!`);
846
- console.log("");
847
- console.log("Instance details:");
848
- console.log(` Name: ${instanceName}`);
849
- console.log(` Runtime: ${runtimeUrl}`);
850
- console.log("");
851
- }
852
-
853
- if (keepAlive) {
854
- const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
855
- const healthTarget = "Gateway";
856
- const POLL_INTERVAL_MS = 5000;
857
- const MAX_FAILURES = 3;
858
- let consecutiveFailures = 0;
859
-
860
- const shutdown = async (): Promise<void> => {
861
- console.log("\nShutting down local processes...");
862
- await stopLocalProcesses(resources);
863
- process.exit(0);
864
- };
865
-
866
- process.on("SIGTERM", () => void shutdown());
867
- process.on("SIGINT", () => void shutdown());
868
-
869
- // Poll the health endpoint until it stops responding.
870
- while (true) {
871
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
872
- try {
873
- const res = await fetch(healthUrl, {
874
- signal: AbortSignal.timeout(3000),
875
- });
876
- if (res.ok) {
877
- consecutiveFailures = 0;
878
- } else {
879
- consecutiveFailures++;
880
- }
881
- } catch {
882
- consecutiveFailures++;
883
- }
884
- if (consecutiveFailures >= MAX_FAILURES) {
885
- console.log(
886
- `\n⚠️ ${healthTarget} stopped responding — shutting down.`,
887
- );
888
- await stopLocalProcesses(resources);
889
- process.exit(1);
890
- }
891
- }
892
- }
893
- }
509
+ export { hatchLocal };
894
510
 
895
511
  function getCliVersion(): string {
896
512
  return cliPkg.version ?? "unknown";
@@ -1,11 +1,5 @@
1
- import { spawn } from "child_process";
2
- import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
3
- import { basename, dirname, join } from "path";
4
-
5
1
  import {
6
2
  findAssistantByName,
7
- getBaseDir,
8
- loadAllAssistants,
9
3
  removeAssistantEntry,
10
4
  } from "../lib/assistant-config";
11
5
  import type { AssistantEntry } from "../lib/assistant-config";
@@ -17,11 +11,7 @@ import {
17
11
  import { retireInstance as retireAwsInstance } from "../lib/aws";
18
12
  import { retireDocker } from "../lib/docker";
19
13
  import { retireInstance as retireGcpInstance } from "../lib/gcp";
20
- import {
21
- stopOrphanedDaemonProcesses,
22
- stopProcessByPidFile,
23
- } from "../lib/process";
24
- import { getArchivePath, getMetadataPath } from "../lib/retire-archive";
14
+ import { retireLocal } from "../lib/retire-local";
25
15
  import { exec } from "../lib/step-runner";
26
16
  import {
27
17
  openLogFile,
@@ -52,115 +42,7 @@ function extractHostFromUrl(url: string): string {
52
42
  }
53
43
  }
54
44
 
55
- async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
56
- console.log("\u{1F5D1}\ufe0f Stopping local assistant...\n");
57
-
58
- if (!entry.resources) {
59
- throw new Error(
60
- `Local assistant '${name}' is missing resource configuration. Re-hatch to fix.`,
61
- );
62
- }
63
- const resources = entry.resources;
64
- const vellumDir = join(resources.instanceDir, ".vellum");
65
-
66
- // Check whether another local assistant shares the same data directory.
67
- const otherSharesDir = loadAllAssistants().some((other) => {
68
- if (other.cloud !== "local") return false;
69
- if (other.assistantId === name) return false;
70
- if (!other.resources) return false;
71
- const otherVellumDir = join(other.resources.instanceDir, ".vellum");
72
- return otherVellumDir === vellumDir;
73
- });
74
-
75
- if (otherSharesDir) {
76
- console.log(
77
- ` Skipping process stop and archive — another local assistant shares ${vellumDir}.`,
78
- );
79
- console.log("\u2705 Local instance retired (config entry removed only).");
80
- return;
81
- }
82
-
83
- const daemonPidFile = resources.pidFile;
84
- const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon");
85
-
86
- // Stop gateway via PID file — use a longer timeout because the gateway has a
87
- // drain window (5s) before it exits.
88
- const gatewayPidFile = join(vellumDir, "gateway.pid");
89
- await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
90
-
91
- // Stop Qdrant — the daemon's graceful shutdown tries to stop it via
92
- // qdrantManager.stop(), but if the daemon was SIGKILL'd (after 2s timeout)
93
- // Qdrant may still be running as an orphan. Check both the current PID file
94
- // location and the legacy location.
95
- const qdrantPidFile = join(
96
- vellumDir,
97
- "workspace",
98
- "data",
99
- "qdrant",
100
- "qdrant.pid",
101
- );
102
- const qdrantLegacyPidFile = join(vellumDir, "qdrant.pid");
103
- await stopProcessByPidFile(qdrantPidFile, "qdrant", undefined, 5000);
104
- await stopProcessByPidFile(qdrantLegacyPidFile, "qdrant", undefined, 5000);
105
-
106
- // If the PID file didn't track a running daemon, scan for orphaned
107
- // daemon processes that may have been started without writing a PID.
108
- if (!daemonStopped) {
109
- await stopOrphanedDaemonProcesses();
110
- }
111
-
112
- // For named instances (instanceDir differs from the base directory),
113
- // archive and remove the entire instance directory. For the default
114
- // instance, archive only the .vellum subdirectory.
115
- const isNamedInstance = resources.instanceDir !== getBaseDir();
116
- const dirToArchive = isNamedInstance ? resources.instanceDir : vellumDir;
117
-
118
- // Move the data directory out of the way so the path is immediately available
119
- // for the next hatch, then kick off the tar archive in the background.
120
- const archivePath = getArchivePath(name);
121
- const metadataPath = getMetadataPath(name);
122
- const stagingDir = `${archivePath}.staging`;
123
-
124
- if (!existsSync(dirToArchive)) {
125
- console.log(
126
- ` No data directory at ${dirToArchive} — nothing to archive.`,
127
- );
128
- console.log("\u2705 Local instance retired.");
129
- return;
130
- }
131
-
132
- // Ensure the retired archive directory exists before attempting the rename
133
- mkdirSync(dirname(stagingDir), { recursive: true });
134
-
135
- try {
136
- renameSync(dirToArchive, stagingDir);
137
- } catch (err) {
138
- // Re-throw so the caller (and the desktop app) knows the archive failed.
139
- // If the rename fails, old workspace data stays in place and a subsequent
140
- // hatch would inherit stale SOUL.md, IDENTITY.md, and memories.
141
- throw new Error(
142
- `Failed to archive ${dirToArchive}: ${err instanceof Error ? err.message : err}`,
143
- );
144
- }
145
-
146
- writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n");
147
-
148
- // Spawn tar + cleanup in the background and detach so the CLI can exit
149
- // immediately. The staging directory is removed once the archive is written.
150
- const tarCmd = [
151
- `tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(dirname(stagingDir))} ${JSON.stringify(basename(stagingDir))}`,
152
- `rm -rf ${JSON.stringify(stagingDir)}`,
153
- ].join(" && ");
154
-
155
- const child = spawn("sh", ["-c", tarCmd], {
156
- stdio: "ignore",
157
- detached: true,
158
- });
159
- child.unref();
160
-
161
- console.log(`📦 Archiving to ${archivePath} in the background.`);
162
- console.log("\u2705 Local instance retired.");
163
- }
45
+ export { retireLocal };
164
46
 
165
47
  async function retireCustom(entry: AssistantEntry): Promise<void> {
166
48
  const host = extractHostFromUrl(entry.runtimeUrl);