@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 +1 -1
- package/src/lib/__tests__/docker.test.ts +99 -0
- package/src/lib/docker.ts +92 -29
package/package.json
CHANGED
|
@@ -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
|
|
825
|
-
* and packages
|
|
826
|
-
*
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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("
|
|
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 () => {
|