@vellumai/cli 0.1.7 → 0.1.9
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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/commands/hatch.ts +323 -69
- package/src/commands/retire.ts +61 -23
- package/src/commands/sleep.ts +63 -0
- package/src/index.ts +3 -0
- package/src/lib/assistant-config.ts +1 -0
- package/src/lib/aws.ts +8 -1
- package/src/lib/gcp.ts +1 -0
- package/src/lib/interfaces-seed.ts +15 -8
- package/src/lib/openclaw-runtime-server.ts +11 -3
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ vellum-cli hatch [species] [options]
|
|
|
39
39
|
|
|
40
40
|
#### Remote Targets
|
|
41
41
|
|
|
42
|
-
- **`local`** -- Starts
|
|
42
|
+
- **`local`** -- Starts the local daemon and local gateway. Gateway source resolution order is: `VELLUM_GATEWAY_DIR` override, repo source tree, then installed `@vellumai/vellum-gateway` package.
|
|
43
43
|
- **`gcp`** -- Creates a GCP Compute Engine VM (`e2-standard-4`: 4 vCPUs, 16 GB) with a startup script that bootstraps the assistant. Requires `gcloud` authentication and `GCP_PROJECT` / `GCP_DEFAULT_ZONE` environment variables.
|
|
44
44
|
- **`aws`** -- Provisions an AWS instance.
|
|
45
45
|
- **`custom`** -- Provisions on an arbitrary SSH host. Set `VELLUM_CUSTOM_HOST` (e.g. `user@hostname`) to specify the target.
|
|
@@ -52,6 +52,8 @@ vellum-cli hatch [species] [options]
|
|
|
52
52
|
| `GCP_PROJECT` | `gcp` | GCP project ID. Falls back to the active `gcloud` project. |
|
|
53
53
|
| `GCP_DEFAULT_ZONE` | `gcp` | GCP zone for the compute instance. |
|
|
54
54
|
| `VELLUM_CUSTOM_HOST` | `custom` | SSH host in `user@hostname` format. |
|
|
55
|
+
| `VELLUM_GATEWAY_DIR` | `local` | Optional absolute path to a local gateway source directory to run instead of the packaged gateway. |
|
|
56
|
+
| `INGRESS_PUBLIC_BASE_URL` | `local` | Optional fallback public ingress URL when `ingress.publicBaseUrl` is not set in workspace config. |
|
|
55
57
|
|
|
56
58
|
#### Examples
|
|
57
59
|
|
package/package.json
CHANGED
package/src/commands/hatch.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { randomBytes } from "crypto";
|
|
3
|
-
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
4
4
|
import { createRequire } from "module";
|
|
5
|
-
import { tmpdir, userInfo } from "os";
|
|
5
|
+
import { tmpdir, userInfo, homedir } from "os";
|
|
6
6
|
import { dirname, join } from "path";
|
|
7
7
|
|
|
8
8
|
import { buildOpenclawStartupScript } from "../adapters/openclaw";
|
|
@@ -26,8 +26,18 @@ import { exec, execOutput } from "../lib/step-runner";
|
|
|
26
26
|
const _require = createRequire(import.meta.url);
|
|
27
27
|
|
|
28
28
|
const INSTALL_SCRIPT_REMOTE_PATH = "/tmp/vellum-install.sh";
|
|
29
|
-
const INSTALL_SCRIPT_PATH = join(import.meta.dir, "..", "adapters", "install.sh");
|
|
30
29
|
const MACHINE_TYPE = "e2-standard-4"; // 4 vCPUs, 16 GB memory
|
|
30
|
+
|
|
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
|
+
async function resolveInstallScriptPath(): Promise<string | null> {
|
|
34
|
+
const sourcePath = join(import.meta.dir, "..", "adapters", "install.sh");
|
|
35
|
+
if (existsSync(sourcePath)) {
|
|
36
|
+
return sourcePath;
|
|
37
|
+
}
|
|
38
|
+
console.warn("⚠️ Install script not found at", sourcePath, "(expected in compiled binary)");
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
31
41
|
const HATCH_TIMEOUT_MS: Record<Species, number> = {
|
|
32
42
|
vellum: 2 * 60 * 1000,
|
|
33
43
|
openclaw: 10 * 60 * 1000,
|
|
@@ -84,6 +94,8 @@ export async function buildStartupScript(
|
|
|
84
94
|
bearerToken: string,
|
|
85
95
|
sshUser: string,
|
|
86
96
|
anthropicApiKey: string,
|
|
97
|
+
instanceName: string,
|
|
98
|
+
cloud: RemoteHost,
|
|
87
99
|
): Promise<string> {
|
|
88
100
|
const platformUrl = process.env.VELLUM_ASSISTANT_PLATFORM_URL ?? "https://assistant.vellum.ai";
|
|
89
101
|
const timestampRedirect = buildTimestampRedirect();
|
|
@@ -101,7 +113,7 @@ export async function buildStartupScript(
|
|
|
101
113
|
);
|
|
102
114
|
}
|
|
103
115
|
|
|
104
|
-
const interfacesSeed = buildInterfacesSeed();
|
|
116
|
+
const interfacesSeed = await buildInterfacesSeed();
|
|
105
117
|
|
|
106
118
|
return `#!/bin/bash
|
|
107
119
|
set -e
|
|
@@ -113,6 +125,8 @@ ${userSetup}
|
|
|
113
125
|
ANTHROPIC_API_KEY=${anthropicApiKey}
|
|
114
126
|
GATEWAY_RUNTIME_PROXY_ENABLED=true
|
|
115
127
|
RUNTIME_PROXY_BEARER_TOKEN=${bearerToken}
|
|
128
|
+
VELLUM_ASSISTANT_NAME=${instanceName}
|
|
129
|
+
VELLUM_CLOUD=${cloud}
|
|
116
130
|
${interfacesSeed}
|
|
117
131
|
mkdir -p "\$HOME/.vellum"
|
|
118
132
|
cat > "\$HOME/.vellum/.env" << DOTENV_EOF
|
|
@@ -121,6 +135,7 @@ GATEWAY_RUNTIME_PROXY_ENABLED=\$GATEWAY_RUNTIME_PROXY_ENABLED
|
|
|
121
135
|
RUNTIME_PROXY_BEARER_TOKEN=\$RUNTIME_PROXY_BEARER_TOKEN
|
|
122
136
|
INTERFACES_SEED_DIR=\$INTERFACES_SEED_DIR
|
|
123
137
|
RUNTIME_HTTP_PORT=7821
|
|
138
|
+
VELLUM_CLOUD=\$VELLUM_CLOUD
|
|
124
139
|
DOTENV_EOF
|
|
125
140
|
|
|
126
141
|
mkdir -p "\$HOME/.vellum/workspace"
|
|
@@ -135,6 +150,7 @@ CONFIG_EOF
|
|
|
135
150
|
${ownershipFixup}
|
|
136
151
|
|
|
137
152
|
export VELLUM_SSH_USER="\$SSH_USER"
|
|
153
|
+
export VELLUM_ASSISTANT_NAME="\$VELLUM_ASSISTANT_NAME"
|
|
138
154
|
echo "Downloading install script from ${platformUrl}/install.sh..."
|
|
139
155
|
curl -fsSL ${platformUrl}/install.sh -o ${INSTALL_SCRIPT_REMOTE_PATH}
|
|
140
156
|
echo "Install script downloaded (\$(wc -c < ${INSTALL_SCRIPT_REMOTE_PATH}) bytes)"
|
|
@@ -151,6 +167,7 @@ interface HatchArgs {
|
|
|
151
167
|
detached: boolean;
|
|
152
168
|
name: string | null;
|
|
153
169
|
remote: RemoteHost;
|
|
170
|
+
daemonOnly: boolean;
|
|
154
171
|
}
|
|
155
172
|
|
|
156
173
|
function parseArgs(): HatchArgs {
|
|
@@ -159,11 +176,14 @@ function parseArgs(): HatchArgs {
|
|
|
159
176
|
let detached = false;
|
|
160
177
|
let name: string | null = null;
|
|
161
178
|
let remote: RemoteHost = DEFAULT_REMOTE;
|
|
179
|
+
let daemonOnly = false;
|
|
162
180
|
|
|
163
181
|
for (let i = 0; i < args.length; i++) {
|
|
164
182
|
const arg = args[i];
|
|
165
183
|
if (arg === "-d") {
|
|
166
184
|
detached = true;
|
|
185
|
+
} else if (arg === "--daemon-only") {
|
|
186
|
+
daemonOnly = true;
|
|
167
187
|
} else if (arg === "--name") {
|
|
168
188
|
const next = args[i + 1];
|
|
169
189
|
if (!next || next.startsWith("-")) {
|
|
@@ -186,13 +206,13 @@ function parseArgs(): HatchArgs {
|
|
|
186
206
|
species = arg as Species;
|
|
187
207
|
} else {
|
|
188
208
|
console.error(
|
|
189
|
-
`Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
|
|
209
|
+
`Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
|
|
190
210
|
);
|
|
191
211
|
process.exit(1);
|
|
192
212
|
}
|
|
193
213
|
}
|
|
194
214
|
|
|
195
|
-
return { species, detached, name, remote };
|
|
215
|
+
return { species, detached, name, remote, daemonOnly };
|
|
196
216
|
}
|
|
197
217
|
|
|
198
218
|
export interface PollResult {
|
|
@@ -301,14 +321,16 @@ async function recoverFromCurlFailure(
|
|
|
301
321
|
sshUser: string,
|
|
302
322
|
account?: string,
|
|
303
323
|
): Promise<void> {
|
|
304
|
-
|
|
305
|
-
|
|
324
|
+
const installScriptPath = await resolveInstallScriptPath();
|
|
325
|
+
if (!installScriptPath) {
|
|
326
|
+
console.warn("⚠️ Skipping install script upload (not available in compiled binary)");
|
|
327
|
+
return;
|
|
306
328
|
}
|
|
307
329
|
|
|
308
330
|
const scpArgs = [
|
|
309
331
|
"compute",
|
|
310
332
|
"scp",
|
|
311
|
-
|
|
333
|
+
installScriptPath,
|
|
312
334
|
`${instanceName}:${INSTALL_SCRIPT_REMOTE_PATH}`,
|
|
313
335
|
`--zone=${zone}`,
|
|
314
336
|
`--project=${project}`,
|
|
@@ -502,7 +524,14 @@ async function hatchGcp(
|
|
|
502
524
|
console.error("Error: ANTHROPIC_API_KEY environment variable is not set.");
|
|
503
525
|
process.exit(1);
|
|
504
526
|
}
|
|
505
|
-
const startupScript = await buildStartupScript(
|
|
527
|
+
const startupScript = await buildStartupScript(
|
|
528
|
+
species,
|
|
529
|
+
bearerToken,
|
|
530
|
+
sshUser,
|
|
531
|
+
anthropicApiKey,
|
|
532
|
+
instanceName,
|
|
533
|
+
"gcp",
|
|
534
|
+
);
|
|
506
535
|
const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
|
|
507
536
|
writeFileSync(startupScriptPath, startupScript);
|
|
508
537
|
|
|
@@ -674,19 +703,31 @@ async function hatchCustom(
|
|
|
674
703
|
process.exit(1);
|
|
675
704
|
}
|
|
676
705
|
|
|
677
|
-
const startupScript = await buildStartupScript(
|
|
706
|
+
const startupScript = await buildStartupScript(
|
|
707
|
+
species,
|
|
708
|
+
bearerToken,
|
|
709
|
+
sshUser,
|
|
710
|
+
anthropicApiKey,
|
|
711
|
+
instanceName,
|
|
712
|
+
"custom",
|
|
713
|
+
);
|
|
678
714
|
const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
|
|
679
715
|
writeFileSync(startupScriptPath, startupScript);
|
|
680
716
|
|
|
681
717
|
try {
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
"
|
|
685
|
-
"
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
718
|
+
const installScriptPath = await resolveInstallScriptPath();
|
|
719
|
+
if (installScriptPath) {
|
|
720
|
+
console.log("📋 Uploading install script to instance...");
|
|
721
|
+
await exec("scp", [
|
|
722
|
+
"-o", "StrictHostKeyChecking=no",
|
|
723
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
724
|
+
"-o", "LogLevel=ERROR",
|
|
725
|
+
installScriptPath,
|
|
726
|
+
`${host}:${INSTALL_SCRIPT_REMOTE_PATH}`,
|
|
727
|
+
]);
|
|
728
|
+
} else {
|
|
729
|
+
console.warn("⚠️ Skipping install script upload (not available in compiled binary)");
|
|
730
|
+
}
|
|
690
731
|
|
|
691
732
|
console.log("📋 Uploading startup script to instance...");
|
|
692
733
|
const remoteStartupPath = `/tmp/${instanceName}-startup.sh`;
|
|
@@ -739,59 +780,239 @@ async function hatchCustom(
|
|
|
739
780
|
}
|
|
740
781
|
}
|
|
741
782
|
|
|
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
|
+
|
|
742
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
|
+
|
|
743
816
|
const sourceDir = join(import.meta.dir, "..", "..", "..", "gateway");
|
|
744
|
-
if (
|
|
817
|
+
if (isGatewaySourceDir(sourceDir)) {
|
|
745
818
|
return sourceDir;
|
|
746
819
|
}
|
|
747
820
|
|
|
821
|
+
const cwdSourceDir = findGatewaySourceFromCwd();
|
|
822
|
+
if (cwdSourceDir) {
|
|
823
|
+
return cwdSourceDir;
|
|
824
|
+
}
|
|
825
|
+
|
|
748
826
|
try {
|
|
749
827
|
const pkgPath = _require.resolve("@vellumai/vellum-gateway/package.json");
|
|
750
828
|
return dirname(pkgPath);
|
|
751
829
|
} catch {
|
|
752
830
|
throw new Error(
|
|
753
|
-
"Gateway not found. Ensure @vellumai/vellum-gateway is installed
|
|
831
|
+
"Gateway not found. Ensure @vellumai/vellum-gateway is installed, run from the source tree, or set VELLUM_GATEWAY_DIR.",
|
|
754
832
|
);
|
|
755
833
|
}
|
|
756
834
|
}
|
|
757
835
|
|
|
758
|
-
|
|
759
|
-
|
|
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
|
+
async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false): Promise<void> {
|
|
895
|
+
const instanceName =
|
|
896
|
+
name ?? process.env.VELLUM_ASSISTANT_NAME ?? `${species}-${generateRandomSuffix()}`;
|
|
760
897
|
|
|
761
898
|
console.log(`🥚 Hatching local assistant: ${instanceName}`);
|
|
762
899
|
console.log(` Species: ${species}`);
|
|
763
900
|
console.log("");
|
|
764
901
|
|
|
765
|
-
console.log("🔨 Starting local daemon...");
|
|
766
|
-
|
|
767
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.
|
|
768
905
|
const daemonBinary = join(dirname(process.execPath), "vellum-daemon");
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const
|
|
777
|
-
const
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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");
|
|
784
984
|
}
|
|
785
|
-
await new Promise((r) => setTimeout(r, pollInterval));
|
|
786
|
-
waited += pollInterval;
|
|
787
985
|
}
|
|
788
|
-
|
|
789
|
-
|
|
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");
|
|
790
1002
|
}
|
|
791
1003
|
} else {
|
|
1004
|
+
console.log("🔨 Starting local daemon...");
|
|
1005
|
+
|
|
1006
|
+
// Source tree layout: cli/src/commands/ -> ../../.. -> repo root -> assistant/src/index.ts
|
|
792
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");
|
|
793
1010
|
let assistantIndex = sourceTreeIndex;
|
|
794
1011
|
|
|
1012
|
+
if (!existsSync(assistantIndex)) {
|
|
1013
|
+
assistantIndex = bunxIndex;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
795
1016
|
if (!existsSync(assistantIndex)) {
|
|
796
1017
|
try {
|
|
797
1018
|
const vellumPkgPath = _require.resolve("vellum/package.json");
|
|
@@ -825,44 +1046,77 @@ async function hatchLocal(species: Species, name: string | null): Promise<void>
|
|
|
825
1046
|
});
|
|
826
1047
|
}
|
|
827
1048
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1049
|
+
// The desktop app communicates with the daemon directly via Unix socket,
|
|
1050
|
+
// so the HTTP gateway is only needed for non-desktop (CLI) usage.
|
|
1051
|
+
let runtimeUrl: string;
|
|
1052
|
+
|
|
1053
|
+
if (process.env.VELLUM_DESKTOP_APP) {
|
|
1054
|
+
// No gateway needed — the macOS app uses DaemonClient over the Unix socket.
|
|
1055
|
+
runtimeUrl = "local";
|
|
1056
|
+
} 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>,
|
|
836
1066
|
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
837
1067
|
GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false",
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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;
|
|
842
1081
|
|
|
843
|
-
|
|
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}`;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const baseDataDir = join(process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? userInfo().homedir), ".vellum");
|
|
844
1094
|
const localEntry: AssistantEntry = {
|
|
845
1095
|
assistantId: instanceName,
|
|
846
1096
|
runtimeUrl,
|
|
1097
|
+
baseDataDir,
|
|
847
1098
|
cloud: "local",
|
|
848
1099
|
species,
|
|
849
1100
|
hatchedAt: new Date().toISOString(),
|
|
850
1101
|
};
|
|
851
|
-
|
|
1102
|
+
if (!daemonOnly) {
|
|
1103
|
+
saveAssistantEntry(localEntry);
|
|
852
1104
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1105
|
+
console.log("");
|
|
1106
|
+
console.log(`✅ Local assistant hatched!`);
|
|
1107
|
+
console.log("");
|
|
1108
|
+
console.log("Instance details:");
|
|
1109
|
+
console.log(` Name: ${instanceName}`);
|
|
1110
|
+
console.log(` Runtime: ${runtimeUrl}`);
|
|
1111
|
+
console.log("");
|
|
1112
|
+
}
|
|
860
1113
|
}
|
|
861
1114
|
|
|
862
1115
|
function getCliVersion(): string {
|
|
863
1116
|
try {
|
|
864
|
-
|
|
865
|
-
const
|
|
1117
|
+
// Use createRequire for JSON import — works in both Bun dev and compiled binary.
|
|
1118
|
+
const require = createRequire(import.meta.url);
|
|
1119
|
+
const pkg = require("../../package.json") as { version?: string };
|
|
866
1120
|
return pkg.version ?? "unknown";
|
|
867
1121
|
} catch {
|
|
868
1122
|
return "unknown";
|
|
@@ -873,10 +1127,10 @@ export async function hatch(): Promise<void> {
|
|
|
873
1127
|
const cliVersion = getCliVersion();
|
|
874
1128
|
console.log(`@vellumai/cli v${cliVersion}`);
|
|
875
1129
|
|
|
876
|
-
const { species, detached, name, remote } = parseArgs();
|
|
1130
|
+
const { species, detached, name, remote, daemonOnly } = parseArgs();
|
|
877
1131
|
|
|
878
1132
|
if (remote === "local") {
|
|
879
|
-
await hatchLocal(species, name);
|
|
1133
|
+
await hatchLocal(species, name, daemonOnly);
|
|
880
1134
|
return;
|
|
881
1135
|
}
|
|
882
1136
|
|
package/src/commands/retire.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import { rmSync } from "fs";
|
|
2
|
+
import { existsSync, readFileSync, rmSync, unlinkSync } from "fs";
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
|
|
@@ -34,30 +34,68 @@ function extractHostFromUrl(url: string): string {
|
|
|
34
34
|
async function retireLocal(): Promise<void> {
|
|
35
35
|
console.log("\u{1F5D1}\ufe0f Stopping local daemon...\n");
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
37
|
+
const vellumDir = join(homedir(), ".vellum");
|
|
38
|
+
const isDesktopApp = !!process.env.VELLUM_DESKTOP_APP;
|
|
39
|
+
|
|
40
|
+
// Stop daemon via PID file (works for both desktop app and standalone)
|
|
41
|
+
const pidFile = join(vellumDir, "vellum.pid");
|
|
42
|
+
const socketFile = join(vellumDir, "vellum.sock");
|
|
43
|
+
|
|
44
|
+
if (existsSync(pidFile)) {
|
|
45
|
+
try {
|
|
46
|
+
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
47
|
+
if (!isNaN(pid)) {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 0); // Check if alive
|
|
50
|
+
process.kill(pid, "SIGTERM");
|
|
51
|
+
const deadline = Date.now() + 2000;
|
|
52
|
+
while (Date.now() < deadline) {
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, 0);
|
|
55
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
56
|
+
} catch {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
process.kill(pid, "SIGKILL");
|
|
63
|
+
} catch {}
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
} catch {}
|
|
67
|
+
try { unlinkSync(pidFile); } catch {}
|
|
68
|
+
try { unlinkSync(socketFile); } catch {}
|
|
69
|
+
}
|
|
52
70
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
if (!isDesktopApp) {
|
|
72
|
+
// Non-desktop: also stop daemon via bunx (fallback) and kill gateway
|
|
73
|
+
try {
|
|
74
|
+
const child = spawn("bunx", ["vellum", "daemon", "stop"], {
|
|
75
|
+
stdio: "inherit",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await new Promise<void>((resolve) => {
|
|
79
|
+
child.on("close", () => resolve());
|
|
80
|
+
child.on("error", () => resolve());
|
|
81
|
+
});
|
|
82
|
+
} catch {}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const killGateway = spawn("pkill", ["-f", "gateway/src/index.ts"], {
|
|
86
|
+
stdio: "ignore",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await new Promise<void>((resolve) => {
|
|
90
|
+
killGateway.on("close", () => resolve());
|
|
91
|
+
killGateway.on("error", () => resolve());
|
|
92
|
+
});
|
|
93
|
+
} catch {}
|
|
94
|
+
|
|
95
|
+
// Only delete ~/.vellum in non-desktop mode
|
|
96
|
+
rmSync(vellumDir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
58
98
|
|
|
59
|
-
const vellumDir = join(homedir(), ".vellum");
|
|
60
|
-
rmSync(vellumDir, { recursive: true, force: true });
|
|
61
99
|
console.log("\u2705 Local instance retired.");
|
|
62
100
|
}
|
|
63
101
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
export async function sleep(): Promise<void> {
|
|
6
|
+
const vellumDir = join(homedir(), ".vellum");
|
|
7
|
+
const pidFile = join(vellumDir, "vellum.pid");
|
|
8
|
+
const socketFile = join(vellumDir, "vellum.sock");
|
|
9
|
+
|
|
10
|
+
if (!existsSync(pidFile)) {
|
|
11
|
+
console.log("No daemon PID file found — nothing to stop.");
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const pidStr = readFileSync(pidFile, "utf-8").trim();
|
|
16
|
+
const pid = parseInt(pidStr, 10);
|
|
17
|
+
|
|
18
|
+
if (isNaN(pid)) {
|
|
19
|
+
console.log("Invalid PID file contents — cleaning up.");
|
|
20
|
+
try { unlinkSync(pidFile); } catch {}
|
|
21
|
+
try { unlinkSync(socketFile); } catch {}
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Check if process is alive
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
} catch {
|
|
29
|
+
console.log(`Daemon process ${pid} is not running — cleaning up stale files.`);
|
|
30
|
+
try { unlinkSync(pidFile); } catch {}
|
|
31
|
+
try { unlinkSync(socketFile); } catch {}
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(`Stopping daemon (pid ${pid})...`);
|
|
36
|
+
process.kill(pid, "SIGTERM");
|
|
37
|
+
|
|
38
|
+
// Wait up to 2s for graceful exit
|
|
39
|
+
const deadline = Date.now() + 2000;
|
|
40
|
+
while (Date.now() < deadline) {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
44
|
+
} catch {
|
|
45
|
+
break; // Process exited
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Force kill if still alive
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, 0);
|
|
52
|
+
console.log("Daemon did not exit after SIGTERM, sending SIGKILL...");
|
|
53
|
+
process.kill(pid, "SIGKILL");
|
|
54
|
+
} catch {
|
|
55
|
+
// Already dead
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Clean up PID and socket files
|
|
59
|
+
try { unlinkSync(pidFile); } catch {}
|
|
60
|
+
try { unlinkSync(socketFile); } catch {}
|
|
61
|
+
|
|
62
|
+
console.log("Daemon stopped.");
|
|
63
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { hatch } from "./commands/hatch";
|
|
4
4
|
import { retire } from "./commands/retire";
|
|
5
|
+
import { sleep } from "./commands/sleep";
|
|
5
6
|
|
|
6
7
|
const commands = {
|
|
7
8
|
hatch,
|
|
8
9
|
retire,
|
|
10
|
+
sleep,
|
|
9
11
|
} as const;
|
|
10
12
|
|
|
11
13
|
type CommandName = keyof typeof commands;
|
|
@@ -20,6 +22,7 @@ async function main() {
|
|
|
20
22
|
console.log("Commands:");
|
|
21
23
|
console.log(" hatch Create a new assistant instance");
|
|
22
24
|
console.log(" retire Delete an assistant instance");
|
|
25
|
+
console.log(" sleep Stop the daemon process");
|
|
23
26
|
process.exit(0);
|
|
24
27
|
}
|
|
25
28
|
|
package/src/lib/aws.ts
CHANGED
|
@@ -420,7 +420,14 @@ export async function hatchAws(
|
|
|
420
420
|
console.log("\u{1F50D} Finding latest Debian AMI...");
|
|
421
421
|
const amiId = await getLatestDebianAmi(region);
|
|
422
422
|
|
|
423
|
-
const startupScript = await buildStartupScript(
|
|
423
|
+
const startupScript = await buildStartupScript(
|
|
424
|
+
species,
|
|
425
|
+
bearerToken,
|
|
426
|
+
sshUser,
|
|
427
|
+
anthropicApiKey,
|
|
428
|
+
instanceName,
|
|
429
|
+
"aws",
|
|
430
|
+
);
|
|
424
431
|
const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
|
|
425
432
|
writeFileSync(startupScriptPath, startupScript);
|
|
426
433
|
|
package/src/lib/gcp.ts
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
// Read source files using Bun.file() with string concatenation (not join())
|
|
2
|
+
// so Bun's bundler can statically analyze the paths and embed the files
|
|
3
|
+
// in the compiled binary ($bunfs). Files must also be passed via --embed
|
|
4
|
+
// in the bun build --compile invocation.
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
const defaultMainScreenSource = await Bun.file(join(import.meta.dir, "..", "components", "DefaultMainScreen.tsx")).text();
|
|
5
|
-
|
|
6
|
-
function inlineLocalImports(source: string): string {
|
|
6
|
+
function inlineLocalImports(source: string, constantsSource: string): string {
|
|
7
7
|
return source
|
|
8
8
|
.replace(/import\s*\{[^}]*\}\s*from\s*["'][^"']*\/constants["'];?\s*\n/, constantsSource + "\n")
|
|
9
9
|
.replace(/import\s*\{[^}]*\}\s*from\s*["']path["'];?\s*\n/, "");
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function buildInterfacesSeed(): string {
|
|
13
|
-
|
|
12
|
+
export async function buildInterfacesSeed(): Promise<string> {
|
|
13
|
+
try {
|
|
14
|
+
const constantsSource = await Bun.file(import.meta.dir + "/constants.ts").text();
|
|
15
|
+
const defaultMainScreenSource = await Bun.file(import.meta.dir + "/../components/DefaultMainScreen.tsx").text();
|
|
16
|
+
const mainWindowSource = inlineLocalImports(defaultMainScreenSource, constantsSource);
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
return `
|
|
16
19
|
INTERFACES_SEED_DIR="/tmp/interfaces-seed"
|
|
17
20
|
mkdir -p "\$INTERFACES_SEED_DIR/tui"
|
|
18
21
|
cat > "\$INTERFACES_SEED_DIR/tui/main-window.tsx" << 'INTERFACES_SEED_EOF'
|
|
19
22
|
${mainWindowSource}INTERFACES_SEED_EOF
|
|
20
23
|
`;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.warn("⚠️ Could not embed interfaces seed files (expected in compiled binary without --embed):", (err as Error).message);
|
|
26
|
+
return "# interfaces-seed: skipped (source files not available in compiled binary)";
|
|
27
|
+
}
|
|
21
28
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
// Read source file using Bun.file() with string concatenation (not join())
|
|
2
|
+
// so Bun's bundler can statically analyze the path and embed the file
|
|
3
|
+
// in the compiled binary ($bunfs). The file must also be passed via --embed
|
|
4
|
+
// in the bun build --compile invocation.
|
|
2
5
|
|
|
3
6
|
export async function buildOpenclawRuntimeServer(): Promise<string> {
|
|
4
|
-
|
|
7
|
+
try {
|
|
8
|
+
const serverSource = await Bun.file(import.meta.dir + "/../adapters/openclaw-http-server.ts").text();
|
|
5
9
|
|
|
6
|
-
|
|
10
|
+
return `
|
|
7
11
|
cat > /opt/openclaw-runtime-server.ts << 'RUNTIME_SERVER_EOF'
|
|
8
12
|
${serverSource}
|
|
9
13
|
RUNTIME_SERVER_EOF
|
|
@@ -12,4 +16,8 @@ mkdir -p "\$HOME/.vellum"
|
|
|
12
16
|
nohup bun run /opt/openclaw-runtime-server.ts >> "\$HOME/.vellum/http-gateway.log" 2>&1 &
|
|
13
17
|
echo "OpenClaw runtime server started (PID: \$!)"
|
|
14
18
|
`;
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.warn("⚠️ Could not embed openclaw runtime server (expected in compiled binary without --embed):", (err as Error).message);
|
|
21
|
+
return "# openclaw-runtime-server: skipped (source files not available in compiled binary)";
|
|
22
|
+
}
|
|
15
23
|
}
|