@vellumai/cli 0.8.9-staging.1 → 0.8.9-staging.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.9-staging.1",
3
+ "version": "0.8.9-staging.2",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, test, expect } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
2
5
  import {
3
6
  ASSISTANT_INTERNAL_PORT,
4
7
  AVATAR_DEVICE_ENV_VAR,
8
+ collectWatchTargets,
5
9
  dockerResourceNames,
6
10
  resolveAvatarDevicePath,
7
11
  resolveDockerHatchMode,
@@ -277,3 +281,98 @@ describe("resolveDockerHatchMode", () => {
277
281
  ).toEqual({ build: false, watcher: false, fellBackToPull: true });
278
282
  });
279
283
  });
284
+
285
+ describe("collectWatchTargets", () => {
286
+ let repoRoot: string;
287
+
288
+ beforeEach(() => {
289
+ repoRoot = mkdtempSync(join(tmpdir(), "vellum-watch-"));
290
+ });
291
+
292
+ afterEach(() => {
293
+ rmSync(repoRoot, { recursive: true, force: true });
294
+ });
295
+
296
+ function scaffold(
297
+ relDir: string,
298
+ { src = true, pkg = true, dockerfile = false } = {},
299
+ ): void {
300
+ mkdirSync(join(repoRoot, relDir), { recursive: true });
301
+ if (src) mkdirSync(join(repoRoot, relDir, "src"), { recursive: true });
302
+ if (pkg) writeFileSync(join(repoRoot, relDir, "package.json"), "{}");
303
+ if (dockerfile) writeFileSync(join(repoRoot, relDir, "Dockerfile"), "");
304
+ }
305
+
306
+ test("scopes watch targets to src/, package.json, and the Dockerfile", () => {
307
+ // GIVEN the three services (each with a Dockerfile) plus a couple of
308
+ // shared packages (libraries, no Dockerfile)
309
+ scaffold("assistant", { dockerfile: true });
310
+ scaffold("credential-executor", { dockerfile: true });
311
+ scaffold("gateway", { dockerfile: true });
312
+ scaffold("packages/service-contracts");
313
+ scaffold("packages/local-mode");
314
+
315
+ // WHEN we collect the watch targets
316
+ const { dirs, files } = collectWatchTargets(repoRoot);
317
+
318
+ // THEN only the src/ directories are watched recursively
319
+ expect(dirs.sort()).toEqual(
320
+ [
321
+ join(repoRoot, "assistant", "src"),
322
+ join(repoRoot, "credential-executor", "src"),
323
+ join(repoRoot, "gateway", "src"),
324
+ join(repoRoot, "packages", "local-mode", "src"),
325
+ join(repoRoot, "packages", "service-contracts", "src"),
326
+ ].sort(),
327
+ );
328
+
329
+ // AND the package.json manifests and service Dockerfiles are watched as
330
+ // individual files (packages have no Dockerfile, so none is emitted)
331
+ expect(files.sort()).toEqual(
332
+ [
333
+ join(repoRoot, "assistant", "package.json"),
334
+ join(repoRoot, "assistant", "Dockerfile"),
335
+ join(repoRoot, "credential-executor", "package.json"),
336
+ join(repoRoot, "credential-executor", "Dockerfile"),
337
+ join(repoRoot, "gateway", "package.json"),
338
+ join(repoRoot, "gateway", "Dockerfile"),
339
+ join(repoRoot, "packages", "local-mode", "package.json"),
340
+ join(repoRoot, "packages", "service-contracts", "package.json"),
341
+ ].sort(),
342
+ );
343
+ });
344
+
345
+ test("never watches .claude/ command symlinks that crash the watcher", () => {
346
+ // GIVEN an assistant service whose .claude/commands holds a dangling
347
+ // symlink (as it does in a fresh checkout)
348
+ scaffold("assistant");
349
+ mkdirSync(join(repoRoot, "assistant", ".claude", "commands"), {
350
+ recursive: true,
351
+ });
352
+ symlinkSync(
353
+ join(repoRoot, "does-not-exist", "do.md"),
354
+ join(repoRoot, "assistant", ".claude", "commands", "do.md"),
355
+ );
356
+
357
+ // WHEN we collect the watch targets
358
+ const { dirs, files } = collectWatchTargets(repoRoot);
359
+
360
+ // THEN no watched path reaches into the .claude/ tree
361
+ const all = [...dirs, ...files];
362
+ expect(all.some((p) => p.includes(".claude"))).toBe(false);
363
+ expect(dirs).toContain(join(repoRoot, "assistant", "src"));
364
+ });
365
+
366
+ test("skips roots missing a src/ directory or package.json", () => {
367
+ // GIVEN a service with only a manifest and a package with only a src/ dir
368
+ scaffold("gateway", { src: false, pkg: true });
369
+ scaffold("packages/contracts-only", { src: true, pkg: false });
370
+
371
+ // WHEN we collect the watch targets
372
+ const { dirs, files } = collectWatchTargets(repoRoot);
373
+
374
+ // THEN absent paths are not emitted
375
+ expect(dirs).toEqual([join(repoRoot, "packages", "contracts-only", "src")]);
376
+ expect(files).toEqual([join(repoRoot, "gateway", "package.json")]);
377
+ });
378
+ });
package/src/lib/docker.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  existsSync,
5
5
  mkdirSync,
6
6
  readFileSync,
7
+ readdirSync,
7
8
  watch as fsWatch,
8
9
  } from "fs";
9
10
  import { arch, platform } from "os";
@@ -788,6 +789,56 @@ export async function captureImageRefs(
788
789
  return hasAll ? (refs as Record<ServiceName, string>) : null;
789
790
  }
790
791
 
792
+ /**
793
+ * Build the set of paths the hot-reload watcher should observe, scoped to
794
+ * each service's `src/` tree, `package.json` manifest, and `Dockerfile`.
795
+ *
796
+ * We deliberately avoid recursively watching whole service directories.
797
+ * Those contain `.claude/` command symlinks — which dangle in a fresh
798
+ * checkout because they point at the separately-cloned `claude-skills`
799
+ * repo — as well as `node_modules`. `fs.watch(dir, { recursive: true })`
800
+ * traverses those entries and emits an unhandled `error` event on a broken
801
+ * symlink, which crashes the CLI process. Source code only ever lives under
802
+ * `src/`, so watching that tree plus the two manifests that drive the image
803
+ * build (`package.json` and `Dockerfile`) preserves hot-reload without
804
+ * walking into symlinked or generated trees. The `Dockerfile` is watched as
805
+ * an individual file for the same reason — editing build steps should
806
+ * trigger a rebuild, but the file sits next to the symlinked trees we avoid.
807
+ *
808
+ * Returning a plain record keeps this trivially unit-testable — see
809
+ * `__tests__/docker.test.ts`.
810
+ */
811
+ export function collectWatchTargets(repoRoot: string): {
812
+ dirs: string[];
813
+ files: string[];
814
+ } {
815
+ const packagesDir = join(repoRoot, "packages");
816
+ const packageRoots = existsSync(packagesDir)
817
+ ? readdirSync(packagesDir, { withFileTypes: true })
818
+ .filter((entry) => entry.isDirectory())
819
+ .map((entry) => join(packagesDir, entry.name))
820
+ : [];
821
+
822
+ const serviceRoots = [
823
+ join(repoRoot, "assistant"),
824
+ join(repoRoot, "credential-executor"),
825
+ join(repoRoot, "gateway"),
826
+ ...packageRoots,
827
+ ];
828
+
829
+ const dirs: string[] = [];
830
+ const files: string[] = [];
831
+ for (const root of serviceRoots) {
832
+ const srcDir = join(root, "src");
833
+ if (existsSync(srcDir)) dirs.push(srcDir);
834
+ for (const name of ["package.json", "Dockerfile"]) {
835
+ const file = join(root, name);
836
+ if (existsSync(file)) files.push(file);
837
+ }
838
+ }
839
+ return { dirs, files };
840
+ }
841
+
791
842
  /**
792
843
  * Determine which services are affected by a changed file path relative
793
844
  * to the repository root.
@@ -821,9 +872,10 @@ function affectedServices(
821
872
  }
822
873
 
823
874
  /**
824
- * Watch for file changes in the assistant, gateway, credential-executor,
825
- * and packages directories. When changes are detected, rebuild the affected
826
- * images and restart their containers.
875
+ * Watch for source changes across the assistant, gateway, credential-executor,
876
+ * and packages services scoped to each service's `src/` tree, `package.json`,
877
+ * and `Dockerfile` (see `collectWatchTargets`). When changes are detected,
878
+ * rebuild the affected images and restart their containers.
827
879
  */
828
880
  function startFileWatcher(opts: {
829
881
  signingKey?: string;
@@ -837,12 +889,7 @@ function startFileWatcher(opts: {
837
889
  }): () => void {
838
890
  const { gatewayPort, imageTags, instanceName, repoRoot, res } = opts;
839
891
 
840
- const watchDirs = [
841
- join(repoRoot, "assistant"),
842
- join(repoRoot, "credential-executor"),
843
- join(repoRoot, "gateway"),
844
- join(repoRoot, "packages"),
845
- ];
892
+ const { dirs: watchDirs, files: watchFiles } = collectWatchTargets(repoRoot);
846
893
 
847
894
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
848
895
  let pendingServices = new Set<ServiceName>();
@@ -919,37 +966,53 @@ function startFileWatcher(opts: {
919
966
 
920
967
  const watchers: ReturnType<typeof fsWatch>[] = [];
921
968
 
969
+ function onChange(fullPath: string): void {
970
+ const services = affectedServices(fullPath, repoRoot);
971
+ if (services.size === 0) return;
972
+
973
+ for (const s of services) {
974
+ pendingServices.add(s);
975
+ }
976
+
977
+ if (debounceTimer) clearTimeout(debounceTimer);
978
+ debounceTimer = setTimeout(() => {
979
+ debounceTimer = null;
980
+ rebuildAndRestart();
981
+ }, 500);
982
+ }
983
+
922
984
  for (const dir of watchDirs) {
923
- if (!existsSync(dir)) continue;
924
985
  const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
925
986
  if (!filename) return;
926
- if (
927
- filename.includes("node_modules") ||
928
- filename.includes(".env") ||
929
- filename.startsWith(".")
930
- ) {
987
+ if (filename.includes("node_modules") || filename.includes(".env")) {
931
988
  return;
932
989
  }
990
+ onChange(join(dir, filename));
991
+ });
992
+ // fs.watch surfaces transient errors (e.g. an unreadable entry) as an
993
+ // `error` event, which would otherwise crash the process. Log and keep
994
+ // the remaining watchers running.
995
+ watcher.on("error", (err) => {
996
+ console.error(
997
+ `⚠️ File watcher error for ${dir}: ${err instanceof Error ? err.message : err}`,
998
+ );
999
+ });
1000
+ watchers.push(watcher);
1001
+ }
933
1002
 
934
- const fullPath = join(dir, filename);
935
- const services = affectedServices(fullPath, repoRoot);
936
- if (services.size === 0) return;
937
-
938
- for (const s of services) {
939
- pendingServices.add(s);
940
- }
941
-
942
- if (debounceTimer) clearTimeout(debounceTimer);
943
- debounceTimer = setTimeout(() => {
944
- debounceTimer = null;
945
- rebuildAndRestart();
946
- }, 500);
1003
+ for (const file of watchFiles) {
1004
+ const watcher = fsWatch(file, () => onChange(file));
1005
+ watcher.on("error", (err) => {
1006
+ console.error(
1007
+ `⚠️ File watcher error for ${file}: ${err instanceof Error ? err.message : err}`,
1008
+ );
947
1009
  });
948
1010
  watchers.push(watcher);
949
1011
  }
950
1012
 
951
1013
  console.log("👀 Watching for file changes in:");
952
- console.log(" assistant/, gateway/, credential-executor/, packages/");
1014
+ console.log(" <service>/src, <service>/package.json, <service>/Dockerfile");
1015
+ console.log(" for assistant/, gateway/, credential-executor/, packages/*");
953
1016
  console.log("");
954
1017
 
955
1018
  return () => {