@vellumai/cli 0.8.8 → 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.8",
3
+ "version": "0.8.9-staging.2",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -32,6 +32,131 @@ import { syncCloudAssistants } from "../lib/sync-cloud-assistants";
32
32
 
33
33
  const LOGIN_TIMEOUT_MS = 120_000; // 2 minutes
34
34
 
35
+ function escapeHtml(s: string): string {
36
+ return s
37
+ .replace(/&/g, "&")
38
+ .replace(/</g, "&lt;")
39
+ .replace(/>/g, "&gt;")
40
+ .replace(/"/g, "&quot;")
41
+ .replace(/'/g, "&#39;");
42
+ }
43
+
44
+ function renderLoginPage(title: string, subtitle: string, success: boolean): string {
45
+ const checkmarkSvg = `<svg class="icon" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
46
+ <circle cx="28" cy="28" r="28" fill="var(--positive-bg)"/>
47
+ <path class="check" d="M17 28.5L24.5 36L39 21" stroke="var(--positive-fg)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
48
+ </svg>`;
49
+
50
+ const errorSvg = `<svg class="icon" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
51
+ <circle cx="28" cy="28" r="28" fill="var(--negative-bg)"/>
52
+ <path class="cross cross-1" d="M20 20L36 36" stroke="var(--negative-fg)" stroke-width="3.5" stroke-linecap="round" fill="none"/>
53
+ <path class="cross cross-2" d="M36 20L20 36" stroke="var(--negative-fg)" stroke-width="3.5" stroke-linecap="round" fill="none"/>
54
+ </svg>`;
55
+
56
+ return `<!DOCTYPE html>
57
+ <html lang="en">
58
+ <head>
59
+ <meta charset="utf-8">
60
+ <meta name="viewport" content="width=device-width, initial-scale=1">
61
+ <title>${escapeHtml(title)}</title>
62
+ <style>
63
+ :root {
64
+ --surface: #F5F3EB;
65
+ --surface-card: #FFFFFF;
66
+ --card-border: #E8E6DA;
67
+ --text-primary: #2A2A28;
68
+ --text-secondary: #4A4A46;
69
+ --positive-bg: #D4DFD0;
70
+ --positive-fg: #516748;
71
+ --negative-bg: #F7DAC9;
72
+ --negative-fg: #DA491A;
73
+ --shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.06);
74
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
75
+ }
76
+ @media (prefers-color-scheme: dark) {
77
+ :root {
78
+ --surface: #1A1A18;
79
+ --surface-card: #2A2A28;
80
+ --card-border: #3A3A37;
81
+ --text-primary: #F5F3EB;
82
+ --text-secondary: #BDB9A9;
83
+ --positive-bg: #1A2316;
84
+ --positive-fg: #7A8B6F;
85
+ --negative-bg: #4E281D;
86
+ --negative-fg: #E86B40;
87
+ --shadow: 0 1px 3px rgba(0,0,0,0.2), 0 4px 12px rgba(0,0,0,0.3);
88
+ }
89
+ }
90
+ * { margin: 0; padding: 0; box-sizing: border-box; }
91
+ body {
92
+ font-family: var(--font);
93
+ background: var(--surface);
94
+ color: var(--text-primary);
95
+ display: flex;
96
+ align-items: center;
97
+ justify-content: center;
98
+ min-height: 100vh;
99
+ -webkit-font-smoothing: antialiased;
100
+ }
101
+ .card {
102
+ text-align: center;
103
+ padding: 48px 40px 40px;
104
+ background: var(--surface-card);
105
+ border: 1px solid var(--card-border);
106
+ border-radius: 16px;
107
+ box-shadow: var(--shadow);
108
+ max-width: 380px;
109
+ width: 100%;
110
+ opacity: 0;
111
+ transform: translateY(8px) scale(0.98);
112
+ animation: cardIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards;
113
+ }
114
+ @keyframes cardIn {
115
+ to { opacity: 1; transform: translateY(0) scale(1); }
116
+ }
117
+ .icon {
118
+ width: 56px;
119
+ height: 56px;
120
+ margin-bottom: 20px;
121
+ }
122
+ .check {
123
+ stroke-dasharray: 32;
124
+ stroke-dashoffset: 32;
125
+ animation: draw 0.4s ease-out 0.45s forwards;
126
+ }
127
+ .cross {
128
+ stroke-dasharray: 22;
129
+ stroke-dashoffset: 22;
130
+ }
131
+ .cross-1 { animation: draw 0.3s ease-out 0.45s forwards; }
132
+ .cross-2 { animation: draw 0.3s ease-out 0.55s forwards; }
133
+ @keyframes draw {
134
+ to { stroke-dashoffset: 0; }
135
+ }
136
+ h1 {
137
+ font-size: 18px;
138
+ font-weight: 600;
139
+ letter-spacing: -0.2px;
140
+ color: var(--text-primary);
141
+ margin-bottom: 6px;
142
+ }
143
+ p {
144
+ font-size: 13px;
145
+ line-height: 1.5;
146
+ color: var(--text-secondary);
147
+ }
148
+ </style>
149
+ </head>
150
+ <body>
151
+ <div class="card">
152
+ ${success ? checkmarkSvg : errorSvg}
153
+ <h1>${escapeHtml(title)}</h1>
154
+ <p>${escapeHtml(subtitle)}</p>
155
+ </div>
156
+ </body>
157
+ </html>`;
158
+ }
159
+
35
160
  /**
36
161
  * Open a URL in the user's default browser.
37
162
  */
@@ -72,26 +197,20 @@ function browserLogin(webUrl: string): Promise<string> {
72
197
 
73
198
  if (receivedState !== state) {
74
199
  res.writeHead(400, { "Content-Type": "text/html" });
75
- res.end(
76
- "<html><body><h2>Login failed</h2><p>State mismatch. Please try again.</p></body></html>",
77
- );
200
+ res.end(renderLoginPage("Login Failed", "State mismatch. Please try again.", false));
78
201
  cleanup("State mismatch — possible CSRF attack.");
79
202
  return;
80
203
  }
81
204
 
82
205
  if (!sessionToken) {
83
206
  res.writeHead(400, { "Content-Type": "text/html" });
84
- res.end(
85
- "<html><body><h2>Login failed</h2><p>No session token received. Please try again.</p></body></html>",
86
- );
207
+ res.end(renderLoginPage("Login Failed", "No session token received. Please try again.", false));
87
208
  cleanup("No session token received from platform.");
88
209
  return;
89
210
  }
90
211
 
91
212
  res.writeHead(200, { "Content-Type": "text/html" });
92
- res.end(
93
- "<html><body><h2>Login successful!</h2><p>You can close this window and return to your terminal.</p></body></html>",
94
- );
213
+ res.end(renderLoginPage("Login Successful", "You can close this window and return to your terminal.", true));
95
214
  cleanup(null, sessionToken);
96
215
  });
97
216
 
@@ -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 () => {