@vellumai/cli 0.8.8-dev.202606080544.8b7fbff → 0.8.8-dev.202606081339.938c6ec

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-dev.202606080544.8b7fbff",
3
+ "version": "0.8.8-dev.202606081339.938c6ec",
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,92 @@ 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(relDir: string, { src = true, pkg = true } = {}): void {
297
+ if (src) mkdirSync(join(repoRoot, relDir, "src"), { recursive: true });
298
+ if (pkg) {
299
+ mkdirSync(join(repoRoot, relDir), { recursive: true });
300
+ writeFileSync(join(repoRoot, relDir, "package.json"), "{}");
301
+ }
302
+ }
303
+
304
+ test("scopes watch targets to each service's src/ tree and package.json", () => {
305
+ // GIVEN the three services plus a couple of shared packages, each with a
306
+ // src/ directory and a package.json manifest
307
+ scaffold("assistant");
308
+ scaffold("credential-executor");
309
+ scaffold("gateway");
310
+ scaffold("packages/service-contracts");
311
+ scaffold("packages/local-mode");
312
+
313
+ // WHEN we collect the watch targets
314
+ const { dirs, files } = collectWatchTargets(repoRoot);
315
+
316
+ // THEN only the src/ directories are watched recursively
317
+ expect(dirs.sort()).toEqual(
318
+ [
319
+ join(repoRoot, "assistant", "src"),
320
+ join(repoRoot, "credential-executor", "src"),
321
+ join(repoRoot, "gateway", "src"),
322
+ join(repoRoot, "packages", "local-mode", "src"),
323
+ join(repoRoot, "packages", "service-contracts", "src"),
324
+ ].sort(),
325
+ );
326
+
327
+ // AND only the package.json manifests are watched as files
328
+ expect(files.sort()).toEqual(
329
+ [
330
+ join(repoRoot, "assistant", "package.json"),
331
+ join(repoRoot, "credential-executor", "package.json"),
332
+ join(repoRoot, "gateway", "package.json"),
333
+ join(repoRoot, "packages", "local-mode", "package.json"),
334
+ join(repoRoot, "packages", "service-contracts", "package.json"),
335
+ ].sort(),
336
+ );
337
+ });
338
+
339
+ test("never watches .claude/ command symlinks that crash the watcher", () => {
340
+ // GIVEN an assistant service whose .claude/commands holds a dangling
341
+ // symlink (as it does in a fresh checkout)
342
+ scaffold("assistant");
343
+ mkdirSync(join(repoRoot, "assistant", ".claude", "commands"), {
344
+ recursive: true,
345
+ });
346
+ symlinkSync(
347
+ join(repoRoot, "does-not-exist", "do.md"),
348
+ join(repoRoot, "assistant", ".claude", "commands", "do.md"),
349
+ );
350
+
351
+ // WHEN we collect the watch targets
352
+ const { dirs, files } = collectWatchTargets(repoRoot);
353
+
354
+ // THEN no watched path reaches into the .claude/ tree
355
+ const all = [...dirs, ...files];
356
+ expect(all.some((p) => p.includes(".claude"))).toBe(false);
357
+ expect(dirs).toContain(join(repoRoot, "assistant", "src"));
358
+ });
359
+
360
+ test("skips roots missing a src/ directory or package.json", () => {
361
+ // GIVEN a service with only a manifest and a package with only a src/ dir
362
+ scaffold("gateway", { src: false, pkg: true });
363
+ scaffold("packages/contracts-only", { src: true, pkg: false });
364
+
365
+ // WHEN we collect the watch targets
366
+ const { dirs, files } = collectWatchTargets(repoRoot);
367
+
368
+ // THEN absent paths are not emitted
369
+ expect(dirs).toEqual([join(repoRoot, "packages", "contracts-only", "src")]);
370
+ expect(files).toEqual([join(repoRoot, "gateway", "package.json")]);
371
+ });
372
+ });
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,51 @@ 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 and `package.json` manifest.
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/` (plus the manifest), so watching those paths preserves hot-reload
803
+ * without walking into symlinked or generated trees.
804
+ *
805
+ * Returning a plain record keeps this trivially unit-testable — see
806
+ * `__tests__/docker.test.ts`.
807
+ */
808
+ export function collectWatchTargets(repoRoot: string): {
809
+ dirs: string[];
810
+ files: string[];
811
+ } {
812
+ const packagesDir = join(repoRoot, "packages");
813
+ const packageRoots = existsSync(packagesDir)
814
+ ? readdirSync(packagesDir, { withFileTypes: true })
815
+ .filter((entry) => entry.isDirectory())
816
+ .map((entry) => join(packagesDir, entry.name))
817
+ : [];
818
+
819
+ const serviceRoots = [
820
+ join(repoRoot, "assistant"),
821
+ join(repoRoot, "credential-executor"),
822
+ join(repoRoot, "gateway"),
823
+ ...packageRoots,
824
+ ];
825
+
826
+ const dirs: string[] = [];
827
+ const files: string[] = [];
828
+ for (const root of serviceRoots) {
829
+ const srcDir = join(root, "src");
830
+ if (existsSync(srcDir)) dirs.push(srcDir);
831
+ const manifest = join(root, "package.json");
832
+ if (existsSync(manifest)) files.push(manifest);
833
+ }
834
+ return { dirs, files };
835
+ }
836
+
791
837
  /**
792
838
  * Determine which services are affected by a changed file path relative
793
839
  * to the repository root.
@@ -821,9 +867,10 @@ function affectedServices(
821
867
  }
822
868
 
823
869
  /**
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.
870
+ * Watch for source changes across the assistant, gateway, credential-executor,
871
+ * and packages services scoped to each service's `src/` tree and
872
+ * `package.json` (see `collectWatchTargets`). When changes are detected,
873
+ * rebuild the affected images and restart their containers.
827
874
  */
828
875
  function startFileWatcher(opts: {
829
876
  signingKey?: string;
@@ -837,12 +884,7 @@ function startFileWatcher(opts: {
837
884
  }): () => void {
838
885
  const { gatewayPort, imageTags, instanceName, repoRoot, res } = opts;
839
886
 
840
- const watchDirs = [
841
- join(repoRoot, "assistant"),
842
- join(repoRoot, "credential-executor"),
843
- join(repoRoot, "gateway"),
844
- join(repoRoot, "packages"),
845
- ];
887
+ const { dirs: watchDirs, files: watchFiles } = collectWatchTargets(repoRoot);
846
888
 
847
889
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
848
890
  let pendingServices = new Set<ServiceName>();
@@ -919,37 +961,53 @@ function startFileWatcher(opts: {
919
961
 
920
962
  const watchers: ReturnType<typeof fsWatch>[] = [];
921
963
 
964
+ function onChange(fullPath: string): void {
965
+ const services = affectedServices(fullPath, repoRoot);
966
+ if (services.size === 0) return;
967
+
968
+ for (const s of services) {
969
+ pendingServices.add(s);
970
+ }
971
+
972
+ if (debounceTimer) clearTimeout(debounceTimer);
973
+ debounceTimer = setTimeout(() => {
974
+ debounceTimer = null;
975
+ rebuildAndRestart();
976
+ }, 500);
977
+ }
978
+
922
979
  for (const dir of watchDirs) {
923
- if (!existsSync(dir)) continue;
924
980
  const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
925
981
  if (!filename) return;
926
- if (
927
- filename.includes("node_modules") ||
928
- filename.includes(".env") ||
929
- filename.startsWith(".")
930
- ) {
982
+ if (filename.includes("node_modules") || filename.includes(".env")) {
931
983
  return;
932
984
  }
985
+ onChange(join(dir, filename));
986
+ });
987
+ // fs.watch surfaces transient errors (e.g. an unreadable entry) as an
988
+ // `error` event, which would otherwise crash the process. Log and keep
989
+ // the remaining watchers running.
990
+ watcher.on("error", (err) => {
991
+ console.error(
992
+ `⚠️ File watcher error for ${dir}: ${err instanceof Error ? err.message : err}`,
993
+ );
994
+ });
995
+ watchers.push(watcher);
996
+ }
933
997
 
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);
998
+ for (const file of watchFiles) {
999
+ const watcher = fsWatch(file, () => onChange(file));
1000
+ watcher.on("error", (err) => {
1001
+ console.error(
1002
+ `⚠️ File watcher error for ${file}: ${err instanceof Error ? err.message : err}`,
1003
+ );
947
1004
  });
948
1005
  watchers.push(watcher);
949
1006
  }
950
1007
 
951
1008
  console.log("👀 Watching for file changes in:");
952
- console.log(" assistant/, gateway/, credential-executor/, packages/");
1009
+ console.log(" <service>/src and <service>/package.json for");
1010
+ console.log(" assistant/, gateway/, credential-executor/, packages/*");
953
1011
  console.log("");
954
1012
 
955
1013
  return () => {