@vellumai/cli 0.4.42 → 0.4.43

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.
@@ -0,0 +1,319 @@
1
+ import { spawn as nodeSpawn } from "child_process";
2
+ import { existsSync } from "fs";
3
+ import { createRequire } from "module";
4
+ import { dirname, join } from "path";
5
+
6
+ import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
7
+ import type { AssistantEntry } from "./assistant-config";
8
+ import { DEFAULT_GATEWAY_PORT } from "./constants";
9
+ import type { Species } from "./constants";
10
+ import { discoverPublicUrl } from "./local";
11
+ import { generateRandomSuffix } from "./random-name";
12
+ import { exec, execOutput } from "./step-runner";
13
+ import { closeLogFile, openLogFile, writeToLogFile } from "./xdg-log";
14
+
15
+ const _require = createRequire(import.meta.url);
16
+
17
+ interface DockerRoot {
18
+ /** Directory to use as the Docker build context */
19
+ root: string;
20
+ /** Relative path from root to the directory containing the Dockerfiles */
21
+ dockerfileDir: string;
22
+ }
23
+
24
+ /**
25
+ * Locate the directory containing the Dockerfile. In the source tree the
26
+ * Dockerfiles live under `meta/`, but when installed as an npm package they
27
+ * are at the package root.
28
+ */
29
+ function findDockerRoot(): DockerRoot {
30
+ // Source tree: cli/src/lib/ -> repo root (Dockerfiles in meta/)
31
+ const sourceTreeRoot = join(import.meta.dir, "..", "..", "..");
32
+ if (existsSync(join(sourceTreeRoot, "meta", "Dockerfile"))) {
33
+ return { root: sourceTreeRoot, dockerfileDir: "meta" };
34
+ }
35
+
36
+ // bunx layout: @vellumai/cli/src/lib/ -> ../../../.. -> node_modules -> vellum/
37
+ const bunxRoot = join(import.meta.dir, "..", "..", "..", "..", "vellum");
38
+ if (existsSync(join(bunxRoot, "Dockerfile"))) {
39
+ return { root: bunxRoot, dockerfileDir: "." };
40
+ }
41
+
42
+ // Walk up from cwd looking for meta/Dockerfile (source checkout)
43
+ let dir = process.cwd();
44
+ while (true) {
45
+ if (existsSync(join(dir, "meta", "Dockerfile"))) {
46
+ return { root: dir, dockerfileDir: "meta" };
47
+ }
48
+ const parent = dirname(dir);
49
+ if (parent === dir) break;
50
+ dir = parent;
51
+ }
52
+
53
+ // Fall back to Node module resolution for the `vellum` package
54
+ try {
55
+ const vellumPkgPath = _require.resolve("vellum/package.json");
56
+ const vellumDir = dirname(vellumPkgPath);
57
+ if (existsSync(join(vellumDir, "Dockerfile"))) {
58
+ return { root: vellumDir, dockerfileDir: "." };
59
+ }
60
+ } catch {
61
+ // resolution failed
62
+ }
63
+
64
+ throw new Error(
65
+ "Could not find Dockerfile. Run this command from within the " +
66
+ "vellum-assistant repository, or ensure the vellum package is installed.",
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Creates a line-buffered output prefixer that prepends `[docker]` to each
72
+ * line from the container's stdout/stderr. Calls `onLine` for each complete
73
+ * line so the caller can detect sentinel output (e.g. hatch completion).
74
+ */
75
+ function createLinePrefixer(
76
+ stream: NodeJS.WritableStream,
77
+ onLine?: (line: string) => void,
78
+ ): { write(data: Buffer): void; flush(): void } {
79
+ let remainder = "";
80
+ return {
81
+ write(data: Buffer) {
82
+ const text = remainder + data.toString();
83
+ const lines = text.split("\n");
84
+ remainder = lines.pop() ?? "";
85
+ for (const line of lines) {
86
+ stream.write(` [docker] ${line}\n`);
87
+ onLine?.(line);
88
+ }
89
+ },
90
+ flush() {
91
+ if (remainder) {
92
+ stream.write(` [docker] ${remainder}\n`);
93
+ onLine?.(remainder);
94
+ remainder = "";
95
+ }
96
+ },
97
+ };
98
+ }
99
+
100
+ async function fetchRemoteBearerToken(
101
+ containerName: string,
102
+ ): Promise<string | null> {
103
+ try {
104
+ const remoteCmd =
105
+ 'cat ~/.vellum.lock.json 2>/dev/null || cat ~/.vellum.lockfile.json 2>/dev/null || echo "{}"';
106
+ const output = await execOutput("docker", [
107
+ "exec",
108
+ containerName,
109
+ "sh",
110
+ "-c",
111
+ remoteCmd,
112
+ ]);
113
+ const data = JSON.parse(output.trim());
114
+ const assistants = data.assistants;
115
+ if (Array.isArray(assistants) && assistants.length > 0) {
116
+ const token = assistants[0].bearerToken;
117
+ if (typeof token === "string" && token) {
118
+ return token;
119
+ }
120
+ }
121
+ return null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ export async function retireDocker(name: string): Promise<void> {
128
+ console.log(`\u{1F5D1}\ufe0f Stopping Docker container '${name}'...\n`);
129
+
130
+ try {
131
+ await exec("docker", ["stop", name]);
132
+ } catch (error) {
133
+ console.warn(
134
+ `\u26a0\ufe0f Failed to stop container: ${error instanceof Error ? error.message : error}`,
135
+ );
136
+ }
137
+
138
+ try {
139
+ await exec("docker", ["rm", name]);
140
+ } catch (error) {
141
+ console.warn(
142
+ `\u26a0\ufe0f Failed to remove container: ${error instanceof Error ? error.message : error}`,
143
+ );
144
+ }
145
+
146
+ console.log(`\u2705 Docker instance retired.`);
147
+ }
148
+
149
+ export async function hatchDocker(
150
+ species: Species,
151
+ detached: boolean,
152
+ name: string | null,
153
+ watch: boolean,
154
+ ): Promise<void> {
155
+ const { root: repoRoot, dockerfileDir } = findDockerRoot();
156
+ const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
157
+ const dockerfileName = watch ? "Dockerfile.development" : "Dockerfile";
158
+ const dockerfile = join(dockerfileDir, dockerfileName);
159
+ const dockerfilePath = join(repoRoot, dockerfile);
160
+
161
+ if (!existsSync(dockerfilePath)) {
162
+ console.error(`Error: ${dockerfile} not found at ${dockerfilePath}`);
163
+ process.exit(1);
164
+ }
165
+
166
+ console.log(`🥚 Hatching Docker assistant: ${instanceName}`);
167
+ console.log(` Species: ${species}`);
168
+ console.log(` Dockerfile: ${dockerfile}`);
169
+ if (watch) {
170
+ console.log(` Mode: development (watch)`);
171
+ }
172
+ console.log("");
173
+
174
+ const imageTag = `vellum-assistant:${instanceName}`;
175
+ const logFd = openLogFile("hatch.log");
176
+ console.log("🔨 Building Docker image...");
177
+ try {
178
+ await exec("docker", ["build", "-f", dockerfile, "-t", imageTag, "."], {
179
+ cwd: repoRoot,
180
+ });
181
+ } catch (err) {
182
+ const message = err instanceof Error ? err.message : String(err);
183
+ writeToLogFile(logFd, `[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`);
184
+ closeLogFile(logFd);
185
+ throw err;
186
+ }
187
+ closeLogFile(logFd);
188
+ console.log("✅ Docker image built\n");
189
+
190
+ const gatewayPort = DEFAULT_GATEWAY_PORT;
191
+ const runArgs: string[] = [
192
+ "run",
193
+ "--init",
194
+ "--name",
195
+ instanceName,
196
+ "-p",
197
+ `${gatewayPort}:${gatewayPort}`,
198
+ ];
199
+
200
+ // Pass through environment variables the assistant needs
201
+ for (const envVar of [
202
+ "ANTHROPIC_API_KEY",
203
+ "GATEWAY_RUNTIME_PROXY_ENABLED",
204
+ "RUNTIME_PROXY_BEARER_TOKEN",
205
+ "VELLUM_ASSISTANT_PLATFORM_URL",
206
+ ]) {
207
+ if (process.env[envVar]) {
208
+ runArgs.push("-e", `${envVar}=${process.env[envVar]}`);
209
+ }
210
+ }
211
+
212
+ // Pass the instance name so the inner hatch uses the same assistant ID
213
+ // instead of generating a new random one.
214
+ runArgs.push("-e", `VELLUM_ASSISTANT_NAME=${instanceName}`);
215
+
216
+ // Mount source volumes in watch mode for hot reloading
217
+ if (watch) {
218
+ runArgs.push(
219
+ "-v",
220
+ `${join(repoRoot, "assistant", "src")}:/app/assistant/src`,
221
+ "-v",
222
+ `${join(repoRoot, "gateway", "src")}:/app/gateway/src`,
223
+ "-v",
224
+ `${join(repoRoot, "cli", "src")}:/app/cli/src`,
225
+ );
226
+ }
227
+
228
+ const publicUrl = await discoverPublicUrl(gatewayPort);
229
+ const runtimeUrl = publicUrl || `http://localhost:${gatewayPort}`;
230
+ const dockerEntry: AssistantEntry = {
231
+ assistantId: instanceName,
232
+ runtimeUrl,
233
+ cloud: "docker",
234
+ species,
235
+ hatchedAt: new Date().toISOString(),
236
+ };
237
+ saveAssistantEntry(dockerEntry);
238
+ setActiveAssistant(instanceName);
239
+
240
+ // The Dockerfiles already define a CMD that runs `vellum hatch --keep-alive`.
241
+ // Only override CMD when a non-default species is specified, since that
242
+ // requires an extra argument the Dockerfile doesn't include.
243
+ const containerCmd: string[] =
244
+ species !== "vellum"
245
+ ? ["vellum", "hatch", species, ...(watch ? ["--watch"] : []), "--keep-alive"]
246
+ : [];
247
+
248
+ // Always start the container detached so it keeps running after the CLI exits.
249
+ runArgs.push("-d");
250
+ console.log("🚀 Starting Docker container...");
251
+ await exec("docker", [...runArgs, imageTag, ...containerCmd], { cwd: repoRoot });
252
+
253
+ if (detached) {
254
+ console.log("\n✅ Docker assistant hatched!\n");
255
+ console.log("Instance details:");
256
+ console.log(` Name: ${instanceName}`);
257
+ console.log(` Runtime: ${runtimeUrl}`);
258
+ console.log(` Container: ${instanceName}`);
259
+ console.log("");
260
+ console.log(`Stop with: docker stop ${instanceName}`);
261
+ } else {
262
+ console.log(` Container: ${instanceName}`);
263
+ console.log(` Runtime: ${runtimeUrl}`);
264
+ console.log("");
265
+
266
+ // Tail container logs until the inner hatch completes, then exit and
267
+ // leave the container running in the background.
268
+ await new Promise<void>((resolve, reject) => {
269
+ const child = nodeSpawn("docker", ["logs", "-f", instanceName], {
270
+ stdio: ["ignore", "pipe", "pipe"],
271
+ });
272
+
273
+ const handleLine = (line: string): void => {
274
+ if (line.includes("Local assistant hatched!")) {
275
+ process.nextTick(async () => {
276
+ const remoteBearerToken =
277
+ await fetchRemoteBearerToken(instanceName);
278
+ if (remoteBearerToken) {
279
+ dockerEntry.bearerToken = remoteBearerToken;
280
+ saveAssistantEntry(dockerEntry);
281
+ }
282
+
283
+ console.log("");
284
+ console.log(`\u2705 Docker container is up and running!`);
285
+ console.log(` Name: ${instanceName}`);
286
+ console.log(` Runtime: ${runtimeUrl}`);
287
+ console.log("");
288
+ child.kill();
289
+ resolve();
290
+ });
291
+ }
292
+ };
293
+
294
+ const stdoutPrefixer = createLinePrefixer(process.stdout, handleLine);
295
+ const stderrPrefixer = createLinePrefixer(process.stderr, handleLine);
296
+
297
+ child.stdout?.on("data", (data: Buffer) => stdoutPrefixer.write(data));
298
+ child.stderr?.on("data", (data: Buffer) => stderrPrefixer.write(data));
299
+ child.stdout?.on("end", () => stdoutPrefixer.flush());
300
+ child.stderr?.on("end", () => stderrPrefixer.flush());
301
+
302
+ child.on("close", (code) => {
303
+ // The log tail may exit if the container stops before the sentinel
304
+ // is seen, or we killed it after detecting the sentinel.
305
+ if (code === 0 || code === null || code === 130 || code === 137 || code === 143) {
306
+ resolve();
307
+ } else {
308
+ reject(new Error(`Docker container exited with code ${code}`));
309
+ }
310
+ });
311
+ child.on("error", reject);
312
+
313
+ process.on("SIGINT", () => {
314
+ child.kill();
315
+ resolve();
316
+ });
317
+ });
318
+ }
319
+ }
package/src/lib/gcp.ts CHANGED
@@ -3,7 +3,7 @@ import { unlinkSync, writeFileSync } from "fs";
3
3
  import { tmpdir, userInfo } from "os";
4
4
  import { join } from "path";
5
5
 
6
- import { saveAssistantEntry } from "./assistant-config";
6
+ import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
7
7
  import type { AssistantEntry } from "./assistant-config";
8
8
  import { FIREWALL_TAG, GATEWAY_PORT } from "./constants";
9
9
  import type { Species } from "./constants";
@@ -441,6 +441,45 @@ async function recoverFromCurlFailure(
441
441
  } catch {}
442
442
  }
443
443
 
444
+ async function fetchRemoteBearerToken(
445
+ instanceName: string,
446
+ project: string,
447
+ zone: string,
448
+ sshUser: string,
449
+ account?: string,
450
+ ): Promise<string | null> {
451
+ try {
452
+ const remoteCmd =
453
+ 'cat ~/.vellum.lock.json 2>/dev/null || cat ~/.vellum.lockfile.json 2>/dev/null || echo "{}"';
454
+ const args = [
455
+ "compute",
456
+ "ssh",
457
+ `${sshUser}@${instanceName}`,
458
+ `--project=${project}`,
459
+ `--zone=${zone}`,
460
+ "--quiet",
461
+ "--ssh-flag=-o StrictHostKeyChecking=no",
462
+ "--ssh-flag=-o UserKnownHostsFile=/dev/null",
463
+ "--ssh-flag=-o ConnectTimeout=10",
464
+ "--ssh-flag=-o LogLevel=ERROR",
465
+ `--command=${remoteCmd}`,
466
+ ];
467
+ if (account) args.push(`--account=${account}`);
468
+ const output = await execOutput("gcloud", args);
469
+ const data = JSON.parse(output.trim());
470
+ const assistants = data.assistants;
471
+ if (Array.isArray(assistants) && assistants.length > 0) {
472
+ const token = assistants[0].bearerToken;
473
+ if (typeof token === "string" && token) {
474
+ return token;
475
+ }
476
+ }
477
+ return null;
478
+ } catch {
479
+ return null;
480
+ }
481
+ }
482
+
444
483
  export async function hatchGcp(
445
484
  species: Species,
446
485
  detached: boolean,
@@ -599,6 +638,7 @@ export async function hatchGcp(
599
638
  hatchedAt: new Date().toISOString(),
600
639
  };
601
640
  saveAssistantEntry(gcpEntry);
641
+ setActiveAssistant(instanceName);
602
642
 
603
643
  if (detached) {
604
644
  console.log("\ud83d\ude80 Startup script is running on the instance...");
@@ -654,6 +694,18 @@ export async function hatchGcp(
654
694
  }
655
695
  }
656
696
 
697
+ const remoteBearerToken = await fetchRemoteBearerToken(
698
+ instanceName,
699
+ project,
700
+ zone,
701
+ sshUser,
702
+ account,
703
+ );
704
+ if (remoteBearerToken) {
705
+ gcpEntry.bearerToken = remoteBearerToken;
706
+ saveAssistantEntry(gcpEntry);
707
+ }
708
+
657
709
  console.log("Instance details:");
658
710
  console.log(` Name: ${instanceName}`);
659
711
  console.log(` Project: ${project}`);
@@ -0,0 +1,114 @@
1
+ import { readFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ import { DEFAULT_DAEMON_PORT } from "./constants.js";
6
+
7
+ /**
8
+ * Resolve the HTTP port for the daemon runtime server.
9
+ * Uses RUNTIME_HTTP_PORT env var, or the default (7821).
10
+ */
11
+ export function resolveDaemonPort(overridePort?: number): number {
12
+ if (overridePort !== undefined) return overridePort;
13
+ const envPort = process.env.RUNTIME_HTTP_PORT;
14
+ if (envPort) {
15
+ const parsed = parseInt(envPort, 10);
16
+ if (!isNaN(parsed)) return parsed;
17
+ }
18
+ return DEFAULT_DAEMON_PORT;
19
+ }
20
+
21
+ /**
22
+ * Build the base URL for the daemon HTTP server.
23
+ */
24
+ export function buildDaemonUrl(port: number): string {
25
+ return `http://127.0.0.1:${port}`;
26
+ }
27
+
28
+ /**
29
+ * Read the HTTP bearer token from `<vellumDir>/http-token`.
30
+ * Respects BASE_DATA_DIR for named instances.
31
+ * Returns undefined if the token file doesn't exist or is empty.
32
+ */
33
+ export function readHttpToken(instanceDir?: string): string | undefined {
34
+ const baseDataDir =
35
+ instanceDir ??
36
+ (process.env.BASE_DATA_DIR?.trim() || homedir());
37
+ const tokenPath = join(baseDataDir, ".vellum", "http-token");
38
+ try {
39
+ const token = readFileSync(tokenPath, "utf-8").trim();
40
+ return token || undefined;
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Perform an HTTP health check against the daemon's `/healthz` endpoint.
48
+ * Returns true if the daemon responds with HTTP 200, false otherwise.
49
+ *
50
+ * This replaces the socket-based `isSocketResponsive()` check.
51
+ */
52
+ export async function httpHealthCheck(
53
+ port: number,
54
+ timeoutMs = 1500,
55
+ ): Promise<boolean> {
56
+ try {
57
+ const url = `${buildDaemonUrl(port)}/healthz`;
58
+ const response = await fetch(url, {
59
+ signal: AbortSignal.timeout(timeoutMs),
60
+ });
61
+ return response.ok;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Poll the daemon's `/healthz` endpoint until it responds with 200 or the
69
+ * timeout is reached. This replaces `waitForSocketFile()`.
70
+ *
71
+ * Returns true if the daemon became healthy within the timeout, false otherwise.
72
+ */
73
+ export async function waitForDaemonReady(
74
+ port: number,
75
+ timeoutMs = 60000,
76
+ ): Promise<boolean> {
77
+ const start = Date.now();
78
+ while (Date.now() - start < timeoutMs) {
79
+ if (await httpHealthCheck(port)) {
80
+ return true;
81
+ }
82
+ await new Promise((r) => setTimeout(r, 250));
83
+ }
84
+ return false;
85
+ }
86
+
87
+ /**
88
+ * Make an authenticated HTTP request to the daemon.
89
+ *
90
+ * @param port - The daemon's HTTP port
91
+ * @param path - The request path (e.g. `/v1/sessions`)
92
+ * @param options - Fetch options (method, body, etc.)
93
+ * @param bearerToken - The bearer token for authentication
94
+ * @returns The fetch Response
95
+ */
96
+ export async function httpSend(
97
+ port: number,
98
+ path: string,
99
+ options: RequestInit = {},
100
+ bearerToken?: string,
101
+ ): Promise<Response> {
102
+ const url = `${buildDaemonUrl(port)}${path}`;
103
+ const headers: Record<string, string> = {
104
+ "Content-Type": "application/json",
105
+ ...(options.headers as Record<string, string> | undefined),
106
+ };
107
+ if (bearerToken) {
108
+ headers["Authorization"] = `Bearer ${bearerToken}`;
109
+ }
110
+ return fetch(url, {
111
+ ...options,
112
+ headers,
113
+ });
114
+ }