@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.
- package/package.json +1 -1
- package/src/adapters/install.sh +20 -2
- package/src/commands/hatch.ts +12 -640
- package/src/commands/ps.ts +322 -0
- package/src/commands/retire.ts +9 -41
- package/src/commands/sleep.ts +25 -53
- package/src/commands/wake.ts +52 -0
- package/src/index.ts +6 -0
- package/src/lib/assistant-config.ts +4 -0
- package/src/lib/aws.ts +18 -0
- package/src/lib/gcp.ts +430 -3
- package/src/lib/local.ts +323 -0
- package/src/lib/process.ts +93 -0
package/src/lib/local.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { dirname, join } from "path";
|
|
6
|
+
|
|
7
|
+
import { GATEWAY_PORT } from "../lib/constants";
|
|
8
|
+
|
|
9
|
+
const _require = createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
function isGatewaySourceDir(dir: string): boolean {
|
|
12
|
+
const pkgPath = join(dir, "package.json");
|
|
13
|
+
if (!existsSync(pkgPath) || !existsSync(join(dir, "src", "index.ts"))) return false;
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
16
|
+
return pkg.name === "@vellumai/vellum-gateway";
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findGatewaySourceFromCwd(): string | undefined {
|
|
23
|
+
let current = process.cwd();
|
|
24
|
+
while (true) {
|
|
25
|
+
if (isGatewaySourceDir(current)) {
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
28
|
+
const nestedCandidate = join(current, "gateway");
|
|
29
|
+
if (isGatewaySourceDir(nestedCandidate)) {
|
|
30
|
+
return nestedCandidate;
|
|
31
|
+
}
|
|
32
|
+
const parent = dirname(current);
|
|
33
|
+
if (parent === current) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
current = parent;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveGatewayDir(): string {
|
|
41
|
+
const override = process.env.VELLUM_GATEWAY_DIR?.trim();
|
|
42
|
+
if (override) {
|
|
43
|
+
if (!isGatewaySourceDir(override)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`VELLUM_GATEWAY_DIR is set to "${override}", but it is not a valid gateway source directory.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return override;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const sourceDir = join(import.meta.dir, "..", "..", "..", "gateway");
|
|
52
|
+
if (isGatewaySourceDir(sourceDir)) {
|
|
53
|
+
return sourceDir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cwdSourceDir = findGatewaySourceFromCwd();
|
|
57
|
+
if (cwdSourceDir) {
|
|
58
|
+
return cwdSourceDir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const pkgPath = _require.resolve("@vellumai/vellum-gateway/package.json");
|
|
63
|
+
return dirname(pkgPath);
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"Gateway not found. Ensure @vellumai/vellum-gateway is installed, run from the source tree, or set VELLUM_GATEWAY_DIR.",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeIngressUrl(value: unknown): string | undefined {
|
|
72
|
+
if (typeof value !== "string") return undefined;
|
|
73
|
+
const normalized = value.trim().replace(/\/+$/, "");
|
|
74
|
+
return normalized || undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readWorkspaceIngressPublicBaseUrl(): string | undefined {
|
|
78
|
+
const baseDataDir = process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir());
|
|
79
|
+
const workspaceConfigPath = join(baseDataDir, ".vellum", "workspace", "config.json");
|
|
80
|
+
try {
|
|
81
|
+
const raw = JSON.parse(readFileSync(workspaceConfigPath, "utf-8")) as Record<string, unknown>;
|
|
82
|
+
const ingress = raw.ingress as Record<string, unknown> | undefined;
|
|
83
|
+
return normalizeIngressUrl(ingress?.publicBaseUrl);
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function discoverPublicUrl(): Promise<string | undefined> {
|
|
90
|
+
const cloud = process.env.VELLUM_CLOUD;
|
|
91
|
+
if (!cloud || cloud === "local") {
|
|
92
|
+
return `http://localhost:${GATEWAY_PORT}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let externalIp: string | undefined;
|
|
96
|
+
try {
|
|
97
|
+
if (cloud === "gcp") {
|
|
98
|
+
const resp = await fetch(
|
|
99
|
+
"http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
|
|
100
|
+
{ headers: { "Metadata-Flavor": "Google" } },
|
|
101
|
+
);
|
|
102
|
+
if (resp.ok) externalIp = (await resp.text()).trim();
|
|
103
|
+
} else if (cloud === "aws") {
|
|
104
|
+
// Use IMDSv2 (token-based) for compatibility with HttpTokens=required
|
|
105
|
+
const tokenResp = await fetch(
|
|
106
|
+
"http://169.254.169.254/latest/api/token",
|
|
107
|
+
{ method: "PUT", headers: { "X-aws-ec2-metadata-token-ttl-seconds": "30" } },
|
|
108
|
+
);
|
|
109
|
+
if (tokenResp.ok) {
|
|
110
|
+
const token = await tokenResp.text();
|
|
111
|
+
const ipResp = await fetch(
|
|
112
|
+
"http://169.254.169.254/latest/meta-data/public-ipv4",
|
|
113
|
+
{ headers: { "X-aws-ec2-metadata-token": token } },
|
|
114
|
+
);
|
|
115
|
+
if (ipResp.ok) externalIp = (await ipResp.text()).trim();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// metadata service not reachable
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (externalIp) {
|
|
123
|
+
console.log(` Discovered external IP: ${externalIp}`);
|
|
124
|
+
return `http://${externalIp}:${GATEWAY_PORT}`;
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function startLocalDaemon(): Promise<void> {
|
|
130
|
+
if (process.env.VELLUM_DESKTOP_APP) {
|
|
131
|
+
// When running inside the desktop app, the CLI owns the daemon lifecycle.
|
|
132
|
+
// Find the vellum-daemon binary adjacent to the CLI binary.
|
|
133
|
+
const daemonBinary = join(dirname(process.execPath), "vellum-daemon");
|
|
134
|
+
if (!existsSync(daemonBinary)) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`vellum-daemon binary not found at ${daemonBinary}.\n` +
|
|
137
|
+
" Ensure the daemon binary is bundled alongside the CLI in the app bundle.",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const vellumDir = join(homedir(), ".vellum");
|
|
142
|
+
const pidFile = join(vellumDir, "vellum.pid");
|
|
143
|
+
const socketFile = join(vellumDir, "vellum.sock");
|
|
144
|
+
|
|
145
|
+
// If a daemon is already running, skip spawning a new one.
|
|
146
|
+
// This prevents cascading kill→restart cycles when multiple callers
|
|
147
|
+
// invoke hatch() concurrently (setupDaemonClient + ensureDaemonConnected).
|
|
148
|
+
let daemonAlive = false;
|
|
149
|
+
if (existsSync(pidFile)) {
|
|
150
|
+
try {
|
|
151
|
+
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
152
|
+
if (!isNaN(pid)) {
|
|
153
|
+
try {
|
|
154
|
+
process.kill(pid, 0); // Check if alive
|
|
155
|
+
daemonAlive = true;
|
|
156
|
+
console.log(` Daemon already running (pid ${pid})\n`);
|
|
157
|
+
} catch {
|
|
158
|
+
// Process doesn't exist, clean up stale PID file
|
|
159
|
+
try { unlinkSync(pidFile); } catch {}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!daemonAlive) {
|
|
166
|
+
// Remove stale socket so we can detect the fresh one
|
|
167
|
+
try { unlinkSync(socketFile); } catch {}
|
|
168
|
+
|
|
169
|
+
console.log("🔨 Starting daemon...");
|
|
170
|
+
|
|
171
|
+
// Ensure ~/.vellum/ exists for PID/socket files
|
|
172
|
+
mkdirSync(vellumDir, { recursive: true });
|
|
173
|
+
|
|
174
|
+
// Build a minimal environment for the daemon. When launched from the
|
|
175
|
+
// macOS app the CLI inherits a huge environment (XPC_SERVICE_NAME,
|
|
176
|
+
// __CFBundleIdentifier, CLAUDE_CODE_ENTRYPOINT, etc.) that can cause
|
|
177
|
+
// the daemon to take 50+ seconds to start instead of ~1s.
|
|
178
|
+
const daemonEnv: Record<string, string> = {
|
|
179
|
+
HOME: process.env.HOME || homedir(),
|
|
180
|
+
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
181
|
+
VELLUM_DAEMON_TCP_ENABLED: "1",
|
|
182
|
+
};
|
|
183
|
+
// Forward optional config env vars the daemon may need
|
|
184
|
+
for (const key of [
|
|
185
|
+
"ANTHROPIC_API_KEY",
|
|
186
|
+
"BASE_DATA_DIR",
|
|
187
|
+
"VELLUM_DAEMON_TCP_PORT",
|
|
188
|
+
"VELLUM_DAEMON_TCP_HOST",
|
|
189
|
+
"VELLUM_DAEMON_SOCKET",
|
|
190
|
+
"VELLUM_DEBUG",
|
|
191
|
+
"SENTRY_DSN",
|
|
192
|
+
"TMPDIR",
|
|
193
|
+
"USER",
|
|
194
|
+
"LANG",
|
|
195
|
+
]) {
|
|
196
|
+
if (process.env[key]) {
|
|
197
|
+
daemonEnv[key] = process.env[key]!;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const child = spawn(daemonBinary, [], {
|
|
202
|
+
detached: true,
|
|
203
|
+
stdio: "ignore",
|
|
204
|
+
env: daemonEnv,
|
|
205
|
+
});
|
|
206
|
+
child.unref();
|
|
207
|
+
|
|
208
|
+
// Write PID file immediately so the health monitor can find the process
|
|
209
|
+
// and concurrent hatch() calls see it as alive.
|
|
210
|
+
if (child.pid) {
|
|
211
|
+
writeFileSync(pidFile, String(child.pid), "utf-8");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Wait for socket at ~/.vellum/vellum.sock (up to 15s)
|
|
216
|
+
if (!existsSync(socketFile)) {
|
|
217
|
+
const maxWait = 15000;
|
|
218
|
+
const start = Date.now();
|
|
219
|
+
while (Date.now() - start < maxWait) {
|
|
220
|
+
if (existsSync(socketFile)) {
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (existsSync(socketFile)) {
|
|
227
|
+
console.log(" Daemon socket ready\n");
|
|
228
|
+
} else {
|
|
229
|
+
console.log(" ⚠️ Daemon socket did not appear within 15s — continuing anyway\n");
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
console.log("🔨 Starting local daemon...");
|
|
233
|
+
|
|
234
|
+
// Source tree layout: cli/src/commands/ -> ../../.. -> repo root -> assistant/src/index.ts
|
|
235
|
+
const sourceTreeIndex = join(import.meta.dir, "..", "..", "..", "assistant", "src", "index.ts");
|
|
236
|
+
// bunx layout: @vellumai/cli/src/commands/ -> ../../../.. -> node_modules/ -> vellum/src/index.ts
|
|
237
|
+
const bunxIndex = join(import.meta.dir, "..", "..", "..", "..", "vellum", "src", "index.ts");
|
|
238
|
+
let assistantIndex = sourceTreeIndex;
|
|
239
|
+
|
|
240
|
+
if (!existsSync(assistantIndex)) {
|
|
241
|
+
assistantIndex = bunxIndex;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!existsSync(assistantIndex)) {
|
|
245
|
+
try {
|
|
246
|
+
const vellumPkgPath = _require.resolve("vellum/package.json");
|
|
247
|
+
assistantIndex = join(dirname(vellumPkgPath), "src", "index.ts");
|
|
248
|
+
} catch {
|
|
249
|
+
// resolve failed, will fall through to existsSync check below
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!existsSync(assistantIndex)) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
"vellum-daemon binary not found and assistant source not available.\n" +
|
|
256
|
+
" Ensure the daemon binary is bundled alongside the CLI, or run from the source tree.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const child = spawn("bun", ["run", assistantIndex, "daemon", "start"], {
|
|
261
|
+
stdio: "inherit",
|
|
262
|
+
env: {
|
|
263
|
+
...process.env,
|
|
264
|
+
RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await new Promise<void>((resolve, reject) => {
|
|
269
|
+
child.on("close", (code) => {
|
|
270
|
+
if (code === 0) {
|
|
271
|
+
resolve();
|
|
272
|
+
} else {
|
|
273
|
+
reject(new Error(`Daemon start exited with code ${code}`));
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
child.on("error", reject);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function startGateway(): Promise<string> {
|
|
282
|
+
const publicUrl = await discoverPublicUrl();
|
|
283
|
+
if (publicUrl) {
|
|
284
|
+
console.log(` Public URL: ${publicUrl}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
console.log("🌐 Starting gateway...");
|
|
288
|
+
const gatewayDir = resolveGatewayDir();
|
|
289
|
+
const gatewayEnv: Record<string, string> = {
|
|
290
|
+
...process.env as Record<string, string>,
|
|
291
|
+
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
292
|
+
GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false",
|
|
293
|
+
RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
|
|
294
|
+
};
|
|
295
|
+
const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl();
|
|
296
|
+
const ingressPublicBaseUrl =
|
|
297
|
+
workspaceIngressPublicBaseUrl
|
|
298
|
+
?? normalizeIngressUrl(process.env.INGRESS_PUBLIC_BASE_URL);
|
|
299
|
+
if (ingressPublicBaseUrl) {
|
|
300
|
+
gatewayEnv.INGRESS_PUBLIC_BASE_URL = ingressPublicBaseUrl;
|
|
301
|
+
console.log(` Ingress URL: ${ingressPublicBaseUrl}`);
|
|
302
|
+
if (!workspaceIngressPublicBaseUrl) {
|
|
303
|
+
console.log(" (using INGRESS_PUBLIC_BASE_URL env fallback)");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (publicUrl) gatewayEnv.GATEWAY_PUBLIC_URL = publicUrl;
|
|
307
|
+
|
|
308
|
+
const gateway = spawn("bun", ["run", "src/index.ts"], {
|
|
309
|
+
cwd: gatewayDir,
|
|
310
|
+
detached: true,
|
|
311
|
+
stdio: "ignore",
|
|
312
|
+
env: gatewayEnv,
|
|
313
|
+
});
|
|
314
|
+
gateway.unref();
|
|
315
|
+
|
|
316
|
+
if (gateway.pid) {
|
|
317
|
+
const vellumDir = join(homedir(), ".vellum");
|
|
318
|
+
writeFileSync(join(vellumDir, "gateway.pid"), String(gateway.pid), "utf-8");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
console.log("✅ Gateway started\n");
|
|
322
|
+
return publicUrl || `http://localhost:${GATEWAY_PORT}`;
|
|
323
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from "fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if a PID file's process is alive.
|
|
5
|
+
*/
|
|
6
|
+
export function isProcessAlive(pidFile: string): { alive: boolean; pid: number | null } {
|
|
7
|
+
if (!existsSync(pidFile)) {
|
|
8
|
+
return { alive: false, pid: null };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const pidStr = readFileSync(pidFile, "utf-8").trim();
|
|
13
|
+
const pid = parseInt(pidStr, 10);
|
|
14
|
+
if (isNaN(pid)) {
|
|
15
|
+
return { alive: false, pid: null };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
process.kill(pid, 0);
|
|
19
|
+
return { alive: true, pid };
|
|
20
|
+
} catch {
|
|
21
|
+
return { alive: false, pid: null };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Stop a process by PID: SIGTERM, wait up to 2s, then SIGKILL if still alive.
|
|
27
|
+
* Returns true if the process was stopped, false if it wasn't alive.
|
|
28
|
+
*/
|
|
29
|
+
export async function stopProcess(pid: number, label: string): Promise<boolean> {
|
|
30
|
+
try {
|
|
31
|
+
process.kill(pid, 0);
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(`Stopping ${label} (pid ${pid})...`);
|
|
37
|
+
process.kill(pid, "SIGTERM");
|
|
38
|
+
|
|
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;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, 0);
|
|
51
|
+
console.log(`${label} did not exit after SIGTERM, sending SIGKILL...`);
|
|
52
|
+
process.kill(pid, "SIGKILL");
|
|
53
|
+
} catch {
|
|
54
|
+
// Already dead
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Stop a process tracked by a PID file, then clean up the file.
|
|
62
|
+
* Returns true if the process was stopped, false if it wasn't alive.
|
|
63
|
+
*/
|
|
64
|
+
export async function stopProcessByPidFile(
|
|
65
|
+
pidFile: string,
|
|
66
|
+
label: string,
|
|
67
|
+
cleanupFiles?: string[],
|
|
68
|
+
): Promise<boolean> {
|
|
69
|
+
const { alive, pid } = isProcessAlive(pidFile);
|
|
70
|
+
|
|
71
|
+
if (!alive || pid === null) {
|
|
72
|
+
if (existsSync(pidFile)) {
|
|
73
|
+
try { unlinkSync(pidFile); } catch {}
|
|
74
|
+
}
|
|
75
|
+
if (cleanupFiles) {
|
|
76
|
+
for (const f of cleanupFiles) {
|
|
77
|
+
try { unlinkSync(f); } catch {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const stopped = await stopProcess(pid, label);
|
|
84
|
+
|
|
85
|
+
try { unlinkSync(pidFile); } catch {}
|
|
86
|
+
if (cleanupFiles) {
|
|
87
|
+
for (const f of cleanupFiles) {
|
|
88
|
+
try { unlinkSync(f); } catch {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return stopped;
|
|
93
|
+
}
|