@vellumai/cli 0.4.54 → 0.4.56

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/src/lib/docker.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import { spawn as nodeSpawn } from "child_process";
2
- import { existsSync } from "fs";
3
- import { createRequire } from "module";
2
+ import { existsSync, watch as fsWatch } from "fs";
4
3
  import { dirname, join } from "path";
5
4
 
5
+ // Direct import — bun embeds this at compile time so it works in compiled binaries.
6
+ import cliPkg from "../../package.json";
7
+
6
8
  import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
7
9
  import type { AssistantEntry } from "./assistant-config";
8
10
  import { DEFAULT_GATEWAY_PORT } from "./constants";
9
11
  import type { Species } from "./constants";
10
- import { generateRandomSuffix } from "./random-name";
12
+ import { leaseGuardianToken } from "./guardian-token";
13
+ import { generateInstanceName } from "./random-name";
11
14
  import { exec, execOutput } from "./step-runner";
12
15
  import {
13
16
  closeLogFile,
@@ -16,7 +19,18 @@ import {
16
19
  writeToLogFile,
17
20
  } from "./xdg-log";
18
21
 
19
- const _require = createRequire(import.meta.url);
22
+ type ServiceName = "assistant" | "credential-executor" | "gateway";
23
+
24
+ const DOCKERHUB_ORG = "vellumai";
25
+ const DOCKERHUB_IMAGES: Record<ServiceName, string> = {
26
+ assistant: `${DOCKERHUB_ORG}/vellum-assistant`,
27
+ "credential-executor": `${DOCKERHUB_ORG}/vellum-credential-executor`,
28
+ gateway: `${DOCKERHUB_ORG}/vellum-gateway`,
29
+ };
30
+
31
+ /** Internal ports exposed by each service's Dockerfile. */
32
+ const ASSISTANT_INTERNAL_PORT = 3001;
33
+ const GATEWAY_INTERNAL_PORT = 7830;
20
34
 
21
35
  /**
22
36
  * Checks whether the `docker` CLI and daemon are available on the system.
@@ -114,265 +128,557 @@ async function ensureDockerInstalled(): Promise<void> {
114
128
  }
115
129
  }
116
130
 
117
- interface DockerRoot {
118
- /** Directory to use as the Docker build context */
119
- root: string;
120
- /** Relative path from root to the directory containing the Dockerfiles */
121
- dockerfileDir: string;
122
- }
123
-
124
131
  /**
125
- * Locate the directory containing the Dockerfile. In the source tree the
126
- * Dockerfiles live under `meta/`, but when installed as an npm package they
127
- * are at the package root.
132
+ * Creates a line-buffered output prefixer that prepends a tag to each
133
+ * line from a container's stdout/stderr. Calls `onLine` for each complete
134
+ * line so the caller can detect sentinel output (e.g. hatch completion).
128
135
  */
129
- function findDockerRoot(developmentMode: boolean = false): DockerRoot {
130
- // Source tree: cli/src/lib/ -> repo root (Dockerfiles in meta/)
131
- const sourceTreeRoot = join(import.meta.dir, "..", "..", "..");
132
- if (existsSync(join(sourceTreeRoot, "meta", "Dockerfile"))) {
133
- return { root: sourceTreeRoot, dockerfileDir: "meta" };
136
+ function createLinePrefixer(
137
+ stream: NodeJS.WritableStream,
138
+ prefix: string,
139
+ onLine?: (line: string) => void,
140
+ ): { write(data: Buffer): void; flush(): void } {
141
+ let remainder = "";
142
+ return {
143
+ write(data: Buffer) {
144
+ const text = remainder + data.toString();
145
+ const lines = text.split("\n");
146
+ remainder = lines.pop() ?? "";
147
+ for (const line of lines) {
148
+ stream.write(` [${prefix}] ${line}\n`);
149
+ onLine?.(line);
150
+ }
151
+ },
152
+ flush() {
153
+ if (remainder) {
154
+ stream.write(` [${prefix}] ${remainder}\n`);
155
+ onLine?.(remainder);
156
+ remainder = "";
157
+ }
158
+ },
159
+ };
160
+ }
161
+
162
+ /** Derive the Docker resource names from the instance name. */
163
+ function dockerResourceNames(instanceName: string) {
164
+ return {
165
+ assistantContainer: `${instanceName}-assistant`,
166
+ cesContainer: `${instanceName}-credential-executor`,
167
+ dataVolume: `vellum-data-${instanceName}`,
168
+ gatewayContainer: `${instanceName}-gateway`,
169
+ network: `vellum-net-${instanceName}`,
170
+ socketVolume: `vellum-ces-bootstrap-${instanceName}`,
171
+ };
172
+ }
173
+
174
+ /** Silently attempt to stop and remove a Docker container. */
175
+ async function removeContainer(containerName: string): Promise<void> {
176
+ try {
177
+ await exec("docker", ["stop", containerName]);
178
+ } catch {
179
+ // container may not exist or already stopped
134
180
  }
181
+ try {
182
+ await exec("docker", ["rm", containerName]);
183
+ } catch {
184
+ // container may not exist or already removed
185
+ }
186
+ }
187
+
188
+ export async function retireDocker(name: string): Promise<void> {
189
+ console.log(`\u{1F5D1}\ufe0f Stopping Docker containers for '${name}'...\n`);
190
+
191
+ const res = dockerResourceNames(name);
192
+
193
+ await removeContainer(res.cesContainer);
194
+ await removeContainer(res.gatewayContainer);
195
+ await removeContainer(res.assistantContainer);
196
+
197
+ // Also clean up a legacy single-container instance if it exists
198
+ await removeContainer(name);
135
199
 
136
- // bunx layout: @vellumai/cli/src/lib/ -> ../../../.. -> node_modules -> vellum/
137
- const bunxRoot = join(import.meta.dir, "..", "..", "..", "..", "vellum");
138
- if (existsSync(join(bunxRoot, "Dockerfile"))) {
139
- return { root: bunxRoot, dockerfileDir: "." };
200
+ // Remove shared network and volumes
201
+ try {
202
+ await exec("docker", ["network", "rm", res.network]);
203
+ } catch {
204
+ // network may not exist
140
205
  }
206
+ for (const vol of [res.dataVolume, res.socketVolume]) {
207
+ try {
208
+ await exec("docker", ["volume", "rm", vol]);
209
+ } catch {
210
+ // volume may not exist
211
+ }
212
+ }
213
+
214
+ console.log(`\u2705 Docker instance retired.`);
215
+ }
141
216
 
142
- // Walk up from cwd looking for meta/Dockerfile (source checkout)
143
- let dir = process.cwd();
217
+ /**
218
+ * Walk up from `startDir` looking for a directory that contains
219
+ * `assistant/Dockerfile`. Returns the path if found, otherwise `undefined`.
220
+ */
221
+ function walkUpForRepoRoot(startDir: string): string | undefined {
222
+ let dir = startDir;
144
223
  while (true) {
145
- if (existsSync(join(dir, "meta", "Dockerfile"))) {
146
- return { root: dir, dockerfileDir: "meta" };
224
+ if (existsSync(join(dir, "assistant", "Dockerfile"))) {
225
+ return dir;
147
226
  }
148
227
  const parent = dirname(dir);
149
228
  if (parent === dir) break;
150
229
  dir = parent;
151
230
  }
231
+ return undefined;
232
+ }
152
233
 
153
- // In development mode, walk up from the executable path to find the repo
154
- // root. This handles the macOS app bundle case where the binary lives inside
155
- // the repo at e.g. clients/macos/dist/Vellum.app/Contents/MacOS/.
156
- if (developmentMode) {
157
- let execDir = dirname(process.execPath);
158
- while (true) {
159
- if (existsSync(join(execDir, "meta", "Dockerfile.development"))) {
160
- return { root: execDir, dockerfileDir: "meta" };
161
- }
162
- const parent = dirname(execDir);
163
- if (parent === execDir) break;
164
- execDir = parent;
165
- }
234
+ /**
235
+ * Locate the repository root by walking up from `cli/src/lib/` until we
236
+ * find a directory containing the expected Dockerfiles.
237
+ */
238
+ function findRepoRoot(): string {
239
+ // cli/src/lib/ -> repo root (works when running from source via bun)
240
+ const sourceTreeRoot = join(import.meta.dir, "..", "..", "..");
241
+ if (existsSync(join(sourceTreeRoot, "assistant", "Dockerfile"))) {
242
+ return sourceTreeRoot;
166
243
  }
167
244
 
168
- // macOS app bundle: Contents/MacOS/vellum-cli -> Contents/Resources/Dockerfile
169
- const appResourcesDir = join(dirname(process.execPath), "..", "Resources");
170
- if (existsSync(join(appResourcesDir, "Dockerfile"))) {
171
- return { root: appResourcesDir, dockerfileDir: "." };
245
+ // Walk up from the compiled binary's location. When the CLI is bundled
246
+ // inside the macOS app (e.g. .../dist/Vellum.app/Contents/MacOS/vellum-cli),
247
+ // the binary still lives inside the repo tree, so walking up will
248
+ // eventually reach the repo root.
249
+ const execRoot = walkUpForRepoRoot(dirname(process.execPath));
250
+ if (execRoot) {
251
+ return execRoot;
172
252
  }
173
253
 
174
- // Fall back to Node module resolution for the `vellum` package
175
- try {
176
- const vellumPkgPath = _require.resolve("vellum/package.json");
177
- const vellumDir = dirname(vellumPkgPath);
178
- if (existsSync(join(vellumDir, "Dockerfile"))) {
179
- return { root: vellumDir, dockerfileDir: "." };
180
- }
181
- } catch {
182
- // resolution failed
254
+ // Walk up from cwd as a final fallback
255
+ const cwdRoot = walkUpForRepoRoot(process.cwd());
256
+ if (cwdRoot) {
257
+ return cwdRoot;
183
258
  }
184
259
 
185
260
  throw new Error(
186
- "Could not find Dockerfile. Run this command from within the " +
187
- "vellum-assistant repository, or ensure the vellum package is installed.",
261
+ "Could not find repository root containing assistant/Dockerfile. " +
262
+ "Run this command from within the vellum-assistant repository.",
263
+ );
264
+ }
265
+
266
+ interface ServiceImageConfig {
267
+ context: string;
268
+ dockerfile: string;
269
+ tag: string;
270
+ }
271
+
272
+ async function buildImage(config: ServiceImageConfig): Promise<void> {
273
+ await exec(
274
+ "docker",
275
+ ["build", "-f", config.dockerfile, "-t", config.tag, "."],
276
+ { cwd: config.context },
277
+ );
278
+ }
279
+
280
+ function serviceImageConfigs(
281
+ repoRoot: string,
282
+ imageTags: Record<ServiceName, string>,
283
+ ): Record<ServiceName, ServiceImageConfig> {
284
+ return {
285
+ assistant: {
286
+ context: repoRoot,
287
+ dockerfile: "assistant/Dockerfile",
288
+ tag: imageTags.assistant,
289
+ },
290
+ "credential-executor": {
291
+ context: repoRoot,
292
+ dockerfile: "credential-executor/Dockerfile",
293
+ tag: imageTags["credential-executor"],
294
+ },
295
+ gateway: {
296
+ context: join(repoRoot, "gateway"),
297
+ dockerfile: "Dockerfile",
298
+ tag: imageTags.gateway,
299
+ },
300
+ };
301
+ }
302
+
303
+ async function buildAllImages(
304
+ repoRoot: string,
305
+ imageTags: Record<ServiceName, string>,
306
+ ): Promise<void> {
307
+ const configs = serviceImageConfigs(repoRoot, imageTags);
308
+ console.log("🔨 Building all images in parallel...");
309
+ await Promise.all(
310
+ Object.entries(configs).map(async ([name, config]) => {
311
+ await buildImage(config);
312
+ console.log(`✅ ${name} built`);
313
+ }),
188
314
  );
189
315
  }
190
316
 
191
317
  /**
192
- * Creates a line-buffered output prefixer that prepends `[docker]` to each
193
- * line from the container's stdout/stderr. Calls `onLine` for each complete
194
- * line so the caller can detect sentinel output (e.g. hatch completion).
318
+ * Returns a function that builds the `docker run` arguments for a given
319
+ * service. Each container joins a shared Docker bridge network so they
320
+ * can be restarted independently.
195
321
  */
196
- function createLinePrefixer(
197
- stream: NodeJS.WritableStream,
198
- onLine?: (line: string) => void,
199
- ): { write(data: Buffer): void; flush(): void } {
200
- let remainder = "";
322
+ function serviceDockerRunArgs(opts: {
323
+ gatewayPort: number;
324
+ imageTags: Record<ServiceName, string>;
325
+ instanceName: string;
326
+ res: ReturnType<typeof dockerResourceNames>;
327
+ }): Record<ServiceName, () => string[]> {
328
+ const { gatewayPort, imageTags, instanceName, res } = opts;
201
329
  return {
202
- write(data: Buffer) {
203
- const text = remainder + data.toString();
204
- const lines = text.split("\n");
205
- remainder = lines.pop() ?? "";
206
- for (const line of lines) {
207
- stream.write(` [docker] ${line}\n`);
208
- onLine?.(line);
209
- }
210
- },
211
- flush() {
212
- if (remainder) {
213
- stream.write(` [docker] ${remainder}\n`);
214
- onLine?.(remainder);
215
- remainder = "";
330
+ assistant: () => {
331
+ const args: string[] = [
332
+ "run",
333
+ "--init",
334
+ "-d",
335
+ "--name",
336
+ res.assistantContainer,
337
+ `--network=${res.network}`,
338
+ "-v",
339
+ `${res.dataVolume}:/data`,
340
+ "-v",
341
+ `${res.socketVolume}:/run/ces-bootstrap`,
342
+ "-e",
343
+ `VELLUM_ASSISTANT_NAME=${instanceName}`,
344
+ "-e",
345
+ "RUNTIME_HTTP_HOST=0.0.0.0",
346
+ ];
347
+ for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
348
+ if (process.env[envVar]) {
349
+ args.push("-e", `${envVar}=${process.env[envVar]}`);
350
+ }
216
351
  }
352
+ args.push(imageTags.assistant);
353
+ return args;
217
354
  },
355
+ gateway: () => [
356
+ "run",
357
+ "--init",
358
+ "-d",
359
+ "--name",
360
+ res.gatewayContainer,
361
+ `--network=${res.network}`,
362
+ "-p",
363
+ `${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
364
+ "-v",
365
+ `${res.dataVolume}:/data`,
366
+ "-e",
367
+ "BASE_DATA_DIR=/data",
368
+ "-e",
369
+ `GATEWAY_PORT=${GATEWAY_INTERNAL_PORT}`,
370
+ "-e",
371
+ `ASSISTANT_HOST=${res.assistantContainer}`,
372
+ "-e",
373
+ `RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
374
+ imageTags.gateway,
375
+ ],
376
+ "credential-executor": () => [
377
+ "run",
378
+ "--init",
379
+ "-d",
380
+ "--name",
381
+ res.cesContainer,
382
+ `--network=${res.network}`,
383
+ "-v",
384
+ `${res.socketVolume}:/run/ces-bootstrap`,
385
+ "-v",
386
+ `${res.dataVolume}:/data:ro`,
387
+ "-e",
388
+ "CES_MODE=managed",
389
+ "-e",
390
+ "CES_BOOTSTRAP_SOCKET_DIR=/run/ces-bootstrap",
391
+ "-e",
392
+ "CES_ASSISTANT_DATA_MOUNT=/data",
393
+ imageTags["credential-executor"],
394
+ ],
218
395
  };
219
396
  }
220
397
 
221
- async function fetchRemoteBearerToken(
222
- containerName: string,
223
- ): Promise<string | null> {
224
- try {
225
- const remoteCmd =
226
- 'cat ~/.vellum.lock.json 2>/dev/null || cat ~/.vellum.lockfile.json 2>/dev/null || echo "{}"';
227
- const output = await execOutput("docker", [
228
- "exec",
229
- containerName,
230
- "sh",
231
- "-c",
232
- remoteCmd,
233
- ]);
234
- const data = JSON.parse(output.trim());
235
- const assistants = data.assistants;
236
- if (Array.isArray(assistants) && assistants.length > 0) {
237
- const token = assistants[0].bearerToken;
238
- if (typeof token === "string" && token) {
239
- return token;
240
- }
241
- }
242
- return null;
243
- } catch {
244
- return null;
398
+ /** The order in which services must be started. */
399
+ const SERVICE_START_ORDER: ServiceName[] = [
400
+ "assistant",
401
+ "gateway",
402
+ "credential-executor",
403
+ ];
404
+
405
+ /** Start all three containers in dependency order. */
406
+ async function startContainers(opts: {
407
+ gatewayPort: number;
408
+ imageTags: Record<ServiceName, string>;
409
+ instanceName: string;
410
+ res: ReturnType<typeof dockerResourceNames>;
411
+ }): Promise<void> {
412
+ const runArgs = serviceDockerRunArgs(opts);
413
+ for (const service of SERVICE_START_ORDER) {
414
+ console.log(`🚀 Starting ${service} container...`);
415
+ await exec("docker", runArgs[service]());
245
416
  }
246
417
  }
247
418
 
248
- export async function retireDocker(name: string): Promise<void> {
249
- console.log(`\u{1F5D1}\ufe0f Stopping Docker container '${name}'...\n`);
419
+ /** Stop and remove all three containers (ignoring errors). */
420
+ async function stopContainers(
421
+ res: ReturnType<typeof dockerResourceNames>,
422
+ ): Promise<void> {
423
+ await removeContainer(res.cesContainer);
424
+ await removeContainer(res.gatewayContainer);
425
+ await removeContainer(res.assistantContainer);
426
+ }
250
427
 
251
- try {
252
- await exec("docker", ["stop", name]);
253
- } catch (error) {
254
- console.warn(
255
- `\u26a0\ufe0f Failed to stop container: ${error instanceof Error ? error.message : error}`,
256
- );
428
+ /**
429
+ * Determine which services are affected by a changed file path relative
430
+ * to the repository root.
431
+ */
432
+ function affectedServices(
433
+ filePath: string,
434
+ repoRoot: string,
435
+ ): Set<ServiceName> {
436
+ const rel = filePath.startsWith(repoRoot)
437
+ ? filePath.slice(repoRoot.length + 1)
438
+ : filePath;
439
+
440
+ const affected = new Set<ServiceName>();
441
+
442
+ if (rel.startsWith("assistant/")) {
443
+ affected.add("assistant");
444
+ }
445
+ if (rel.startsWith("credential-executor/")) {
446
+ affected.add("credential-executor");
447
+ }
448
+ if (rel.startsWith("gateway/")) {
449
+ affected.add("gateway");
450
+ }
451
+ // Shared packages affect both assistant and credential-executor
452
+ if (rel.startsWith("packages/")) {
453
+ affected.add("assistant");
454
+ affected.add("credential-executor");
257
455
  }
258
456
 
259
- try {
260
- await exec("docker", ["rm", name]);
261
- } catch (error) {
262
- console.warn(
263
- `\u26a0\ufe0f Failed to remove container: ${error instanceof Error ? error.message : error}`,
264
- );
457
+ return affected;
458
+ }
459
+
460
+ /**
461
+ * Watch for file changes in the assistant, gateway, credential-executor,
462
+ * and packages directories. When changes are detected, rebuild the affected
463
+ * images and restart their containers.
464
+ */
465
+ function startFileWatcher(opts: {
466
+ gatewayPort: number;
467
+ imageTags: Record<ServiceName, string>;
468
+ instanceName: string;
469
+ repoRoot: string;
470
+ res: ReturnType<typeof dockerResourceNames>;
471
+ }): () => void {
472
+ const { gatewayPort, imageTags, instanceName, repoRoot, res } = opts;
473
+
474
+ const watchDirs = [
475
+ join(repoRoot, "assistant"),
476
+ join(repoRoot, "credential-executor"),
477
+ join(repoRoot, "gateway"),
478
+ join(repoRoot, "packages"),
479
+ ];
480
+
481
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
482
+ let pendingServices = new Set<ServiceName>();
483
+ let rebuilding = false;
484
+
485
+ const configs = serviceImageConfigs(repoRoot, imageTags);
486
+ const runArgs = serviceDockerRunArgs({
487
+ gatewayPort,
488
+ imageTags,
489
+ instanceName,
490
+ res,
491
+ });
492
+ const containerForService: Record<ServiceName, string> = {
493
+ assistant: res.assistantContainer,
494
+ "credential-executor": res.cesContainer,
495
+ gateway: res.gatewayContainer,
496
+ };
497
+
498
+ async function rebuildAndRestart(): Promise<void> {
499
+ if (rebuilding) return;
500
+ rebuilding = true;
501
+
502
+ const services = pendingServices;
503
+ pendingServices = new Set();
504
+
505
+ const serviceNames = [...services].join(", ");
506
+ console.log(`\n🔄 Changes detected — rebuilding: ${serviceNames}`);
507
+
508
+ try {
509
+ await Promise.all(
510
+ [...services].map(async (service) => {
511
+ console.log(`🔨 Building ${service}...`);
512
+ await buildImage(configs[service]);
513
+ console.log(`✅ ${service} built`);
514
+ }),
515
+ );
516
+
517
+ for (const service of services) {
518
+ const container = containerForService[service];
519
+ console.log(`🔄 Restarting ${container}...`);
520
+ await removeContainer(container);
521
+ await exec("docker", runArgs[service]());
522
+ }
523
+
524
+ console.log("✅ Rebuild complete — watching for changes...\n");
525
+ } catch (err) {
526
+ console.error(
527
+ `❌ Rebuild failed: ${err instanceof Error ? err.message : err}`,
528
+ );
529
+ console.log(" Watching for changes...\n");
530
+ } finally {
531
+ rebuilding = false;
532
+ if (pendingServices.size > 0) {
533
+ rebuildAndRestart();
534
+ }
535
+ }
265
536
  }
266
537
 
267
- console.log(`\u2705 Docker instance retired.`);
538
+ const watchers: ReturnType<typeof fsWatch>[] = [];
539
+
540
+ for (const dir of watchDirs) {
541
+ if (!existsSync(dir)) continue;
542
+ const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
543
+ if (!filename) return;
544
+ if (
545
+ filename.includes("node_modules") ||
546
+ filename.includes(".env") ||
547
+ filename.startsWith(".")
548
+ ) {
549
+ return;
550
+ }
551
+
552
+ const fullPath = join(dir, filename);
553
+ const services = affectedServices(fullPath, repoRoot);
554
+ if (services.size === 0) return;
555
+
556
+ for (const s of services) {
557
+ pendingServices.add(s);
558
+ }
559
+
560
+ if (debounceTimer) clearTimeout(debounceTimer);
561
+ debounceTimer = setTimeout(() => {
562
+ debounceTimer = null;
563
+ rebuildAndRestart();
564
+ }, 500);
565
+ });
566
+ watchers.push(watcher);
567
+ }
568
+
569
+ console.log("👀 Watching for file changes in:");
570
+ console.log(" assistant/, gateway/, credential-executor/, packages/");
571
+ console.log("");
572
+
573
+ return () => {
574
+ for (const watcher of watchers) {
575
+ watcher.close();
576
+ }
577
+ if (debounceTimer) clearTimeout(debounceTimer);
578
+ };
268
579
  }
269
580
 
270
581
  export async function hatchDocker(
271
582
  species: Species,
272
583
  detached: boolean,
273
584
  name: string | null,
274
- watch: boolean,
585
+ watch: boolean = false,
275
586
  ): Promise<void> {
276
587
  resetLogFile("hatch.log");
277
588
 
278
589
  await ensureDockerInstalled();
279
590
 
280
- let repoRoot: string;
281
- let dockerfileDir: string;
282
- try {
283
- ({ root: repoRoot, dockerfileDir } = findDockerRoot(watch));
284
- } catch (err) {
285
- const message = err instanceof Error ? err.message : String(err);
286
- const logFd = openLogFile("hatch.log");
287
- writeToLogFile(
288
- logFd,
289
- `[docker-hatch] ${new Date().toISOString()} ERROR\n${message}\n`,
290
- );
291
- closeLogFile(logFd);
292
- console.error(message);
293
- throw err;
294
- }
591
+ const instanceName = generateInstanceName(species, name);
592
+ const gatewayPort = DEFAULT_GATEWAY_PORT;
295
593
 
296
- const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
297
- const dockerfileName = watch ? "Dockerfile.development" : "Dockerfile";
298
- const dockerfile = join(dockerfileDir, dockerfileName);
299
- const dockerfilePath = join(repoRoot, dockerfile);
594
+ const imageTags: Record<ServiceName, string> = {
595
+ assistant: "",
596
+ "credential-executor": "",
597
+ gateway: "",
598
+ };
300
599
 
301
- if (!existsSync(dockerfilePath)) {
302
- const message = `Error: ${dockerfile} not found at ${dockerfilePath}`;
303
- const logFd = openLogFile("hatch.log");
304
- writeToLogFile(
305
- logFd,
306
- `[docker-hatch] ${new Date().toISOString()} ERROR\n${message}\n`,
307
- );
308
- closeLogFile(logFd);
309
- console.error(message);
310
- process.exit(1);
311
- }
600
+ let repoRoot: string | undefined;
312
601
 
313
- console.log(`🥚 Hatching Docker assistant: ${instanceName}`);
314
- console.log(` Species: ${species}`);
315
- console.log(` Dockerfile: ${dockerfile}`);
316
602
  if (watch) {
603
+ repoRoot = findRepoRoot();
604
+ const localTag = `local-${instanceName}`;
605
+ imageTags.assistant = `vellum-assistant:${localTag}`;
606
+ imageTags.gateway = `vellum-gateway:${localTag}`;
607
+ imageTags["credential-executor"] = `vellum-credential-executor:${localTag}`;
608
+
609
+ console.log(`🥚 Hatching Docker assistant: ${instanceName}`);
610
+ console.log(` Species: ${species}`);
317
611
  console.log(` Mode: development (watch)`);
318
- }
319
- console.log("");
320
-
321
- const imageTag = `vellum-assistant:${instanceName}`;
322
- const logFd = openLogFile("hatch.log");
323
- console.log("🔨 Building Docker image...");
324
- try {
325
- await exec("docker", ["build", "-f", dockerfile, "-t", imageTag, "."], {
326
- cwd: repoRoot,
327
- });
328
- } catch (err) {
329
- const message = err instanceof Error ? err.message : String(err);
330
- writeToLogFile(
331
- logFd,
332
- `[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`,
612
+ console.log(` Repo: ${repoRoot}`);
613
+ console.log(` Images (local build):`);
614
+ console.log(` assistant: ${imageTags.assistant}`);
615
+ console.log(` gateway: ${imageTags.gateway}`);
616
+ console.log(
617
+ ` credential-executor: ${imageTags["credential-executor"]}`,
333
618
  );
334
- closeLogFile(logFd);
335
- throw err;
336
- }
337
- closeLogFile(logFd);
338
- console.log("✅ Docker image built\n");
619
+ console.log("");
339
620
 
340
- const gatewayPort = DEFAULT_GATEWAY_PORT;
341
- const runArgs: string[] = [
342
- "run",
343
- "--init",
344
- "--name",
345
- instanceName,
346
- "-p",
347
- `${gatewayPort}:${gatewayPort}`,
348
- ];
621
+ const logFd = openLogFile("hatch.log");
622
+ try {
623
+ await buildAllImages(repoRoot, imageTags);
624
+ } catch (err) {
625
+ const message = err instanceof Error ? err.message : String(err);
626
+ writeToLogFile(
627
+ logFd,
628
+ `[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`,
629
+ );
630
+ closeLogFile(logFd);
631
+ throw err;
632
+ }
633
+ closeLogFile(logFd);
634
+ console.log("✅ Docker images built\n");
635
+ } else {
636
+ const version = cliPkg.version;
637
+ const versionTag = version ? `v${version}` : "latest";
638
+ imageTags.assistant = `${DOCKERHUB_IMAGES.assistant}:${versionTag}`;
639
+ imageTags.gateway = `${DOCKERHUB_IMAGES.gateway}:${versionTag}`;
640
+ imageTags["credential-executor"] =
641
+ `${DOCKERHUB_IMAGES["credential-executor"]}:${versionTag}`;
642
+
643
+ console.log(`🥚 Hatching Docker assistant: ${instanceName}`);
644
+ console.log(` Species: ${species}`);
645
+ console.log(` Images:`);
646
+ console.log(` assistant: ${imageTags.assistant}`);
647
+ console.log(` gateway: ${imageTags.gateway}`);
648
+ console.log(
649
+ ` credential-executor: ${imageTags["credential-executor"]}`,
650
+ );
651
+ console.log("");
349
652
 
350
- // Pass through environment variables the assistant needs
351
- for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
352
- if (process.env[envVar]) {
353
- runArgs.push("-e", `${envVar}=${process.env[envVar]}`);
653
+ const logFd = openLogFile("hatch.log");
654
+ console.log("📦 Pulling Docker images...");
655
+ try {
656
+ await exec("docker", ["pull", imageTags.assistant]);
657
+ await exec("docker", ["pull", imageTags.gateway]);
658
+ await exec("docker", ["pull", imageTags["credential-executor"]]);
659
+ } catch (err) {
660
+ const message = err instanceof Error ? err.message : String(err);
661
+ writeToLogFile(
662
+ logFd,
663
+ `[docker-pull] ${new Date().toISOString()} ERROR\n${message}\n`,
664
+ );
665
+ closeLogFile(logFd);
666
+ throw err;
354
667
  }
668
+ closeLogFile(logFd);
669
+ console.log("✅ Docker images pulled\n");
355
670
  }
356
671
 
357
- // Pass the instance name so the inner hatch uses the same assistant ID
358
- // instead of generating a new random one.
359
- runArgs.push("-e", `VELLUM_ASSISTANT_NAME=${instanceName}`);
672
+ const res = dockerResourceNames(instanceName);
360
673
 
361
- // Mount source volumes in watch mode for hot reloading
362
- if (watch) {
363
- runArgs.push(
364
- "-v",
365
- `${join(repoRoot, "assistant", "src")}:/app/assistant/src`,
366
- "-v",
367
- `${join(repoRoot, "gateway", "src")}:/app/gateway/src`,
368
- "-v",
369
- `${join(repoRoot, "cli", "src")}:/app/cli/src`,
370
- );
371
- }
674
+ // Create shared network and volumes
675
+ console.log("📁 Creating shared network and volumes...");
676
+ await exec("docker", ["network", "create", res.network]);
677
+ await exec("docker", ["volume", "create", res.dataVolume]);
678
+ await exec("docker", ["volume", "create", res.socketVolume]);
679
+
680
+ await startContainers({ gatewayPort, imageTags, instanceName, res });
372
681
 
373
- // Docker containers bind to 0.0.0.0 so localhost always works. Skip
374
- // mDNS/LAN discovery — the .local hostname often fails to resolve on the
375
- // host machine itself (mDNS is designed for cross-device discovery).
376
682
  const runtimeUrl = `http://localhost:${gatewayPort}`;
377
683
  const dockerEntry: AssistantEntry = {
378
684
  assistantId: instanceName,
@@ -380,101 +686,150 @@ export async function hatchDocker(
380
686
  cloud: "docker",
381
687
  species,
382
688
  hatchedAt: new Date().toISOString(),
689
+ volume: res.dataVolume,
383
690
  };
384
691
  saveAssistantEntry(dockerEntry);
385
692
  setActiveAssistant(instanceName);
386
693
 
387
- // The Dockerfiles already define a CMD that runs `vellum hatch --keep-alive`.
388
- // Only override CMD when a non-default species is specified, since that
389
- // requires an extra argument the Dockerfile doesn't include.
390
- const containerCmd: string[] =
391
- species !== "vellum"
392
- ? [
393
- "vellum",
394
- "hatch",
395
- species,
396
- ...(watch ? ["--watch"] : []),
397
- "--keep-alive",
398
- ]
399
- : [];
400
-
401
- // Always start the container detached so it keeps running after the CLI exits.
402
- runArgs.push("-d");
403
- console.log("🚀 Starting Docker container...");
404
- await exec("docker", [...runArgs, imageTag, ...containerCmd], {
405
- cwd: repoRoot,
694
+ // The assistant image runs the daemon directly (not via the CLI hatch
695
+ // command), so we watch for the DaemonServer readiness message instead
696
+ // of the CLI's "Local assistant hatched!" sentinel.
697
+ await tailContainerUntilReady({
698
+ containerName: res.assistantContainer,
699
+ detached: watch ? false : detached,
700
+ dockerEntry,
701
+ instanceName,
702
+ runtimeUrl,
703
+ sentinel: "DaemonServer started",
406
704
  });
407
705
 
706
+ if (watch && repoRoot) {
707
+ const stopWatcher = startFileWatcher({
708
+ gatewayPort,
709
+ imageTags,
710
+ instanceName,
711
+ repoRoot,
712
+ res,
713
+ });
714
+
715
+ await new Promise<void>((resolve) => {
716
+ const cleanup = async () => {
717
+ console.log("\n🛑 Shutting down...");
718
+ stopWatcher();
719
+ await stopContainers(res);
720
+ console.log("✅ Docker instance stopped.");
721
+ resolve();
722
+ };
723
+
724
+ process.on("SIGINT", () => void cleanup());
725
+ process.on("SIGTERM", () => void cleanup());
726
+ });
727
+ }
728
+ }
729
+
730
+ /**
731
+ * In detached mode, print instance details and return immediately.
732
+ * Otherwise, tail the given container's logs until the sentinel string
733
+ * appears, then attempt to lease a guardian token and report readiness.
734
+ */
735
+ async function tailContainerUntilReady(opts: {
736
+ containerName: string;
737
+ detached: boolean;
738
+ dockerEntry: AssistantEntry;
739
+ instanceName: string;
740
+ runtimeUrl: string;
741
+ sentinel: string;
742
+ }): Promise<void> {
743
+ const {
744
+ containerName,
745
+ detached,
746
+ dockerEntry,
747
+ instanceName,
748
+ runtimeUrl,
749
+ sentinel,
750
+ } = opts;
751
+
408
752
  if (detached) {
409
753
  console.log("\n✅ Docker assistant hatched!\n");
410
754
  console.log("Instance details:");
411
755
  console.log(` Name: ${instanceName}`);
412
756
  console.log(` Runtime: ${runtimeUrl}`);
413
- console.log(` Container: ${instanceName}`);
414
- console.log("");
415
- console.log(`Stop with: docker stop ${instanceName}`);
416
- } else {
417
- console.log(` Container: ${instanceName}`);
418
- console.log(` Runtime: ${runtimeUrl}`);
757
+ console.log(` Container: ${containerName}`);
419
758
  console.log("");
759
+ console.log(`Stop with: vellum retire ${instanceName}`);
760
+ return;
761
+ }
420
762
 
421
- // Tail container logs until the inner hatch completes, then exit and
422
- // leave the container running in the background.
423
- await new Promise<void>((resolve, reject) => {
424
- const child = nodeSpawn("docker", ["logs", "-f", instanceName], {
425
- stdio: ["ignore", "pipe", "pipe"],
426
- });
427
-
428
- const handleLine = (line: string): void => {
429
- if (line.includes("Local assistant hatched!")) {
430
- process.nextTick(async () => {
431
- const remoteBearerToken =
432
- await fetchRemoteBearerToken(instanceName);
433
- if (remoteBearerToken) {
434
- dockerEntry.bearerToken = remoteBearerToken;
435
- saveAssistantEntry(dockerEntry);
436
- }
437
-
438
- console.log("");
439
- console.log(`\u2705 Docker container is up and running!`);
440
- console.log(` Name: ${instanceName}`);
441
- console.log(` Runtime: ${runtimeUrl}`);
442
- console.log("");
443
- child.kill();
444
- resolve();
445
- });
446
- }
447
- };
763
+ console.log(` Container: ${containerName}`);
764
+ console.log(` Runtime: ${runtimeUrl}`);
765
+ console.log("");
766
+
767
+ await new Promise<void>((resolve, reject) => {
768
+ const child = nodeSpawn("docker", ["logs", "-f", containerName], {
769
+ stdio: ["ignore", "pipe", "pipe"],
770
+ });
448
771
 
449
- const stdoutPrefixer = createLinePrefixer(process.stdout, handleLine);
450
- const stderrPrefixer = createLinePrefixer(process.stderr, handleLine);
451
-
452
- child.stdout?.on("data", (data: Buffer) => stdoutPrefixer.write(data));
453
- child.stderr?.on("data", (data: Buffer) => stderrPrefixer.write(data));
454
- child.stdout?.on("end", () => stdoutPrefixer.flush());
455
- child.stderr?.on("end", () => stderrPrefixer.flush());
456
-
457
- child.on("close", (code) => {
458
- // The log tail may exit if the container stops before the sentinel
459
- // is seen, or we killed it after detecting the sentinel.
460
- if (
461
- code === 0 ||
462
- code === null ||
463
- code === 130 ||
464
- code === 137 ||
465
- code === 143
466
- ) {
772
+ const handleLine = (line: string): void => {
773
+ if (line.includes(sentinel)) {
774
+ process.nextTick(async () => {
775
+ try {
776
+ const tokenData = await leaseGuardianToken(
777
+ runtimeUrl,
778
+ instanceName,
779
+ );
780
+ dockerEntry.bearerToken = tokenData.accessToken;
781
+ saveAssistantEntry(dockerEntry);
782
+ } catch (err) {
783
+ console.warn(
784
+ `\u26a0\ufe0f Could not lease guardian token: ${err instanceof Error ? err.message : err}`,
785
+ );
786
+ }
787
+
788
+ console.log("");
789
+ console.log(`\u2705 Docker containers are up and running!`);
790
+ console.log(` Name: ${instanceName}`);
791
+ console.log(` Runtime: ${runtimeUrl}`);
792
+ console.log("");
793
+ child.kill();
467
794
  resolve();
468
- } else {
469
- reject(new Error(`Docker container exited with code ${code}`));
470
- }
471
- });
472
- child.on("error", reject);
795
+ });
796
+ }
797
+ };
798
+
799
+ const stdoutPrefixer = createLinePrefixer(
800
+ process.stdout,
801
+ "docker",
802
+ handleLine,
803
+ );
804
+ const stderrPrefixer = createLinePrefixer(
805
+ process.stderr,
806
+ "docker",
807
+ handleLine,
808
+ );
473
809
 
474
- process.on("SIGINT", () => {
475
- child.kill();
810
+ child.stdout?.on("data", (data: Buffer) => stdoutPrefixer.write(data));
811
+ child.stderr?.on("data", (data: Buffer) => stderrPrefixer.write(data));
812
+ child.stdout?.on("end", () => stdoutPrefixer.flush());
813
+ child.stderr?.on("end", () => stderrPrefixer.flush());
814
+
815
+ child.on("close", (code) => {
816
+ if (
817
+ code === 0 ||
818
+ code === null ||
819
+ code === 130 ||
820
+ code === 137 ||
821
+ code === 143
822
+ ) {
476
823
  resolve();
477
- });
824
+ } else {
825
+ reject(new Error(`Docker container exited with code ${code}`));
826
+ }
478
827
  });
479
- }
828
+ child.on("error", reject);
829
+
830
+ process.on("SIGINT", () => {
831
+ child.kill();
832
+ resolve();
833
+ });
834
+ });
480
835
  }