@vellumai/cli 0.8.8 → 0.8.9-dev.202606091926.ebb2d62

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.
@@ -6,6 +6,8 @@
6
6
  * subscribe to SSE events (use `vellum events` for that).
7
7
  */
8
8
 
9
+ import { readFileSync } from "node:fs";
10
+
9
11
  import { extractFlag } from "../lib/arg-utils.js";
10
12
  import { AssistantClient } from "../lib/assistant-client.js";
11
13
 
@@ -14,57 +16,145 @@ function printUsage(): void {
14
16
 
15
17
  USAGE:
16
18
  vellum message [assistant] <message>
19
+ vellum message [assistant] --file <path>
17
20
 
18
21
  ARGUMENTS:
19
22
  [assistant] Instance name (default: active assistant)
20
- <message> Message content to send
23
+ <message> Message content to send (omit when using --file)
21
24
 
22
25
  OPTIONS:
26
+ --file <path> Read message content from a file ("-" reads stdin)
23
27
  --conversation-key <key> Conversation key (default: stable key per channel/interface)
24
28
  --json Output raw JSON response
25
29
 
26
30
  EXAMPLES:
27
31
  vellum message "hello"
28
32
  vellum message my-assistant "ping"
33
+ vellum message --file prompt.txt
34
+ vellum message my-assistant --file prompt.txt
35
+ cat prompt.txt | vellum message --file -
29
36
  vellum message --conversation-key my-thread "hello"
30
37
  vellum message --json "hello"
31
38
  `);
32
39
  }
33
40
 
34
- export async function message(): Promise<void> {
35
- const rawArgs = process.argv.slice(3);
41
+ interface ParsedMessageArgs {
42
+ assistantId?: string;
43
+ conversationKey?: string;
44
+ jsonOutput: boolean;
45
+ /** Path to read message content from, or undefined for an inline message. */
46
+ filePath?: string;
47
+ /** Inline message content, present only when --file was not used. */
48
+ inlineMessage?: string;
49
+ }
36
50
 
37
- if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
38
- printUsage();
39
- return;
40
- }
51
+ type ParseResult =
52
+ | { ok: true; value: ParsedMessageArgs }
53
+ | { ok: false; error: string };
41
54
 
55
+ /**
56
+ * Parse `vellum message` arguments. Pure: does no I/O and never exits, so the
57
+ * positional/flag rules can be unit-tested. File reading and validation of the
58
+ * resolved content happen in {@link message}.
59
+ */
60
+ export function parseMessageArgs(rawArgs: string[]): ParseResult {
42
61
  const jsonOutput = rawArgs.includes("--json");
43
62
  let args = rawArgs.filter((a) => a !== "--json");
44
63
 
45
- const [conversationKey, filteredArgs] = extractFlag(
64
+ const [conversationKey, afterConversationKey] = extractFlag(
46
65
  args,
47
66
  "--conversation-key",
48
67
  );
49
- args = filteredArgs;
68
+ args = afterConversationKey;
69
+
70
+ const fileFlagPresent = args.includes("--file");
71
+ const [filePath, afterFile] = extractFlag(args, "--file");
72
+ args = afterFile;
73
+
74
+ // `extractFlag` strips a trailing value-less `--file`, which would otherwise
75
+ // make the next positional masquerade as the message content. Reject it.
76
+ if (fileFlagPresent && filePath === undefined) {
77
+ return { ok: false, error: "--file requires a path argument." };
78
+ }
50
79
 
51
- let assistantId: string | undefined;
52
- let messageContent: string | undefined;
80
+ if (filePath !== undefined) {
81
+ // vellum message [assistant] --file <path>
82
+ // The message content comes from the file, so any remaining positional
83
+ // arg is the assistant target.
84
+ if (args.length >= 2) {
85
+ return {
86
+ ok: false,
87
+ error: "--file cannot be combined with an inline message argument.",
88
+ };
89
+ }
90
+ return {
91
+ ok: true,
92
+ value: { assistantId: args[0], conversationKey, jsonOutput, filePath },
93
+ };
94
+ }
53
95
 
54
96
  if (args.length >= 2) {
55
97
  // vellum message <assistant> <message>
56
- assistantId = args[0];
57
- messageContent = args[1];
58
- } else if (args.length === 1) {
98
+ return {
99
+ ok: true,
100
+ value: {
101
+ assistantId: args[0],
102
+ conversationKey,
103
+ jsonOutput,
104
+ inlineMessage: args[1],
105
+ },
106
+ };
107
+ }
108
+ if (args.length === 1) {
59
109
  // vellum message <message> (uses active/latest assistant)
60
- messageContent = args[0];
110
+ return {
111
+ ok: true,
112
+ value: { conversationKey, jsonOutput, inlineMessage: args[0] },
113
+ };
61
114
  }
62
115
 
63
- if (!messageContent) {
64
- console.error("Error: message content is required.");
65
- console.error("");
116
+ return { ok: false, error: "message content is required." };
117
+ }
118
+
119
+ function exitWithUsage(error: string): never {
120
+ console.error(`Error: ${error}`);
121
+ console.error("");
122
+ printUsage();
123
+ process.exit(1);
124
+ }
125
+
126
+ export async function message(): Promise<void> {
127
+ const rawArgs = process.argv.slice(3);
128
+
129
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
66
130
  printUsage();
67
- process.exit(1);
131
+ return;
132
+ }
133
+
134
+ const parsed = parseMessageArgs(rawArgs);
135
+ if (!parsed.ok) {
136
+ exitWithUsage(parsed.error);
137
+ }
138
+
139
+ const { assistantId, conversationKey, jsonOutput, filePath, inlineMessage } =
140
+ parsed.value;
141
+
142
+ let messageContent: string;
143
+ if (filePath !== undefined) {
144
+ try {
145
+ messageContent = readFileSync(filePath === "-" ? 0 : filePath, "utf-8");
146
+ } catch (error) {
147
+ const reason = error instanceof Error ? error.message : String(error);
148
+ console.error(
149
+ `Error: could not read message file "${filePath}": ${reason}`,
150
+ );
151
+ process.exit(1);
152
+ }
153
+ if (messageContent.length === 0) {
154
+ exitWithUsage(`message file "${filePath}" is empty.`);
155
+ }
156
+ } else {
157
+ messageContent = inlineMessage ?? "";
68
158
  }
69
159
 
70
160
  const client = new AssistantClient({ assistantId });
@@ -891,6 +891,7 @@ export async function resolveOrHatchTarget(
891
891
  false,
892
892
  false,
893
893
  {},
894
+ {},
894
895
  {
895
896
  setupProviderCredentials: false,
896
897
  },
@@ -915,6 +916,7 @@ export async function resolveOrHatchTarget(
915
916
  targetName ?? null,
916
917
  false,
917
918
  {},
919
+ {},
918
920
  {
919
921
  setupProviderCredentials: false,
920
922
  },
@@ -12,7 +12,12 @@ import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
12
12
  import { SPECIES_CONFIG, type Species } from "../lib/constants";
13
13
  import { lookupAssistantByIdentifier } from "../lib/assistant-config";
14
14
  import { checkHealth } from "../lib/health-check";
15
- import { loadGuardianToken, refreshGuardianToken } from "../lib/guardian-token";
15
+ import {
16
+ guardianTokenDueForRenewal,
17
+ loadGuardianToken,
18
+ refreshGuardianToken,
19
+ } from "../lib/guardian-token";
20
+ import { trustedRefreshUrl } from "../lib/runtime-url";
16
21
  import { appendHistory, loadHistory } from "../lib/input-history";
17
22
  import { tuiLog } from "../lib/tui-log";
18
23
  import { segmentsToPlainText } from "../lib/segments-to-plain-text";
@@ -193,6 +198,16 @@ function friendlyErrorMessage(status: number, body: string): string {
193
198
  * and access-only tokens. Because the TUI threads one shared `auth` object by
194
199
  * reference, mutating it here propagates to every later request and the SSE
195
200
  * reconnect — no callback threading needed.
201
+ *
202
+ * SECURITY: the refresh is bound to the paired entry's persisted runtime URL.
203
+ * `vellum client` lets `--url`/`-u` override the runtime URL while still using
204
+ * the selected paired entry's stored guardian token, so a victim pointed at an
205
+ * attacker-controlled (or poisoned/redirected) URL that returns 401 must NOT
206
+ * cause us to POST the long-lived refreshToken + deviceId to that origin. We
207
+ * therefore (a) refuse to refresh unless `baseUrl` normalizes to one of the
208
+ * entry's persisted URLs, and (b) send the refresh to the persisted URL rather
209
+ * than the caller-supplied `baseUrl` — defense in depth if the gate is ever
210
+ * bypassed.
196
211
  */
197
212
  export async function maybeRefreshAuthHeaders(
198
213
  baseUrl: string,
@@ -210,11 +225,21 @@ export async function maybeRefreshAuthHeaders(
210
225
  return false;
211
226
  }
212
227
 
228
+ // Bind the refresh origin to the persisted paired entry: refuse (and never
229
+ // leak credentials) if `baseUrl` was overridden via --url or poisoned to an
230
+ // origin that isn't one of the entry's persisted URLs. `refreshUrl` is the
231
+ // trusted persisted URL we actually send to.
232
+ const refreshUrl = trustedRefreshUrl(lookup.entry, baseUrl);
233
+ if (!refreshUrl) return false;
234
+
213
235
  const stored = loadGuardianToken(assistantId);
214
236
  if (!stored || stored.accessToken !== bearer || !stored.refreshToken) {
215
237
  return false;
216
238
  }
217
- const refreshed = await refreshGuardianToken(baseUrl, assistantId);
239
+ // Only refresh once the token is actually due for renewal, so a forged 401
240
+ // on a still-valid token can't coax out the long-lived refresh credential.
241
+ if (!guardianTokenDueForRenewal(stored)) return false;
242
+ const refreshed = await refreshGuardianToken(refreshUrl, assistantId);
218
243
  if (!refreshed?.accessToken) return false;
219
244
  auth["Authorization"] = `Bearer ${refreshed.accessToken}`;
220
245
  return true;
@@ -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
+ });
@@ -14,7 +14,11 @@
14
14
 
15
15
  import { resolveAssistant } from "./assistant-config.js";
16
16
  import { GATEWAY_PORT } from "./constants.js";
17
- import { loadGuardianToken, refreshGuardianToken } from "./guardian-token.js";
17
+ import {
18
+ loadGuardianToken,
19
+ refreshGuardianToken,
20
+ guardianTokenDueForRenewal,
21
+ } from "./guardian-token.js";
18
22
 
19
23
  const DEFAULT_TIMEOUT_MS = 30_000;
20
24
  const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
@@ -219,21 +223,35 @@ export class AssistantClient {
219
223
 
220
224
  const response = await doFetch();
221
225
 
222
- // Reactive auto-refresh: a paired/local guardian access token that has
223
- // expired comes back 401. Refresh it once via the stored refresh credential
224
- // and retry. Self-gating refreshGuardianToken returns null unless a usable
225
- // refresh token is stored, so ephemeral (`--token`) and access-only sessions
226
- // just see the original 401. The platform session-auth path is never
227
- // refreshed here (its token is managed by the Vellum platform).
226
+ // Reactive auto-refresh on a 401 for the guardian (non-session) path.
227
+ // Ephemeral (`--token`) and access-only sessions have no stored refresh
228
+ // credential and just see the original 401; the platform session-auth path
229
+ // is never refreshed here (its token is managed by the Vellum platform).
228
230
  if (response.status === 401 && !this.isSessionAuth) {
229
- const refreshed = await refreshGuardianToken(
230
- this.runtimeUrl,
231
- this._assistantId,
232
- );
233
- if (refreshed?.accessToken) {
234
- this.token = refreshed.accessToken;
231
+ const stored = loadGuardianToken(this._assistantId);
232
+
233
+ // Another process may have already rotated and persisted a fresh access
234
+ // token (e.g. a concurrent `vellum events`). Adopt it and retry — this
235
+ // sends no refresh credential, just picks up the newer local token.
236
+ if (stored?.accessToken && stored.accessToken !== this.token) {
237
+ this.token = stored.accessToken;
235
238
  return doFetch();
236
239
  }
240
+
241
+ // Otherwise only disclose the long-lived refresh token when our access
242
+ // token is actually due for renewal. A 401 on a still-valid token (e.g. a
243
+ // forged 401 from an impostor endpoint trying to coax out the refresh
244
+ // credential) is surfaced as-is, not refreshed.
245
+ if (stored?.refreshToken && guardianTokenDueForRenewal(stored)) {
246
+ const refreshed = await refreshGuardianToken(
247
+ this.runtimeUrl,
248
+ this._assistantId,
249
+ );
250
+ if (refreshed?.accessToken) {
251
+ this.token = refreshed.accessToken;
252
+ return doFetch();
253
+ }
254
+ }
237
255
  }
238
256
 
239
257
  return response;
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";
@@ -661,6 +662,7 @@ export async function startContainers(
661
662
  bootstrapSecret?: string;
662
663
  cesServiceToken?: string;
663
664
  extraAssistantEnv?: Record<string, string>;
665
+ extraGatewayEnv?: Record<string, string>;
664
666
  gatewayPort: number;
665
667
  imageTags: Record<ServiceName, string>;
666
668
  instanceName: string;
@@ -788,6 +790,56 @@ export async function captureImageRefs(
788
790
  return hasAll ? (refs as Record<ServiceName, string>) : null;
789
791
  }
790
792
 
793
+ /**
794
+ * Build the set of paths the hot-reload watcher should observe, scoped to
795
+ * each service's `src/` tree, `package.json` manifest, and `Dockerfile`.
796
+ *
797
+ * We deliberately avoid recursively watching whole service directories.
798
+ * Those contain `.claude/` command symlinks — which dangle in a fresh
799
+ * checkout because they point at the separately-cloned `claude-skills`
800
+ * repo — as well as `node_modules`. `fs.watch(dir, { recursive: true })`
801
+ * traverses those entries and emits an unhandled `error` event on a broken
802
+ * symlink, which crashes the CLI process. Source code only ever lives under
803
+ * `src/`, so watching that tree plus the two manifests that drive the image
804
+ * build (`package.json` and `Dockerfile`) preserves hot-reload without
805
+ * walking into symlinked or generated trees. The `Dockerfile` is watched as
806
+ * an individual file for the same reason — editing build steps should
807
+ * trigger a rebuild, but the file sits next to the symlinked trees we avoid.
808
+ *
809
+ * Returning a plain record keeps this trivially unit-testable — see
810
+ * `__tests__/docker.test.ts`.
811
+ */
812
+ export function collectWatchTargets(repoRoot: string): {
813
+ dirs: string[];
814
+ files: string[];
815
+ } {
816
+ const packagesDir = join(repoRoot, "packages");
817
+ const packageRoots = existsSync(packagesDir)
818
+ ? readdirSync(packagesDir, { withFileTypes: true })
819
+ .filter((entry) => entry.isDirectory())
820
+ .map((entry) => join(packagesDir, entry.name))
821
+ : [];
822
+
823
+ const serviceRoots = [
824
+ join(repoRoot, "assistant"),
825
+ join(repoRoot, "credential-executor"),
826
+ join(repoRoot, "gateway"),
827
+ ...packageRoots,
828
+ ];
829
+
830
+ const dirs: string[] = [];
831
+ const files: string[] = [];
832
+ for (const root of serviceRoots) {
833
+ const srcDir = join(root, "src");
834
+ if (existsSync(srcDir)) dirs.push(srcDir);
835
+ for (const name of ["package.json", "Dockerfile"]) {
836
+ const file = join(root, name);
837
+ if (existsSync(file)) files.push(file);
838
+ }
839
+ }
840
+ return { dirs, files };
841
+ }
842
+
791
843
  /**
792
844
  * Determine which services are affected by a changed file path relative
793
845
  * to the repository root.
@@ -821,9 +873,10 @@ function affectedServices(
821
873
  }
822
874
 
823
875
  /**
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.
876
+ * Watch for source changes across the assistant, gateway, credential-executor,
877
+ * and packages services scoped to each service's `src/` tree, `package.json`,
878
+ * and `Dockerfile` (see `collectWatchTargets`). When changes are detected,
879
+ * rebuild the affected images and restart their containers.
827
880
  */
828
881
  function startFileWatcher(opts: {
829
882
  signingKey?: string;
@@ -837,12 +890,7 @@ function startFileWatcher(opts: {
837
890
  }): () => void {
838
891
  const { gatewayPort, imageTags, instanceName, repoRoot, res } = opts;
839
892
 
840
- const watchDirs = [
841
- join(repoRoot, "assistant"),
842
- join(repoRoot, "credential-executor"),
843
- join(repoRoot, "gateway"),
844
- join(repoRoot, "packages"),
845
- ];
893
+ const { dirs: watchDirs, files: watchFiles } = collectWatchTargets(repoRoot);
846
894
 
847
895
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
848
896
  let pendingServices = new Set<ServiceName>();
@@ -919,37 +967,53 @@ function startFileWatcher(opts: {
919
967
 
920
968
  const watchers: ReturnType<typeof fsWatch>[] = [];
921
969
 
970
+ function onChange(fullPath: string): void {
971
+ const services = affectedServices(fullPath, repoRoot);
972
+ if (services.size === 0) return;
973
+
974
+ for (const s of services) {
975
+ pendingServices.add(s);
976
+ }
977
+
978
+ if (debounceTimer) clearTimeout(debounceTimer);
979
+ debounceTimer = setTimeout(() => {
980
+ debounceTimer = null;
981
+ rebuildAndRestart();
982
+ }, 500);
983
+ }
984
+
922
985
  for (const dir of watchDirs) {
923
- if (!existsSync(dir)) continue;
924
986
  const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
925
987
  if (!filename) return;
926
- if (
927
- filename.includes("node_modules") ||
928
- filename.includes(".env") ||
929
- filename.startsWith(".")
930
- ) {
988
+ if (filename.includes("node_modules") || filename.includes(".env")) {
931
989
  return;
932
990
  }
991
+ onChange(join(dir, filename));
992
+ });
993
+ // fs.watch surfaces transient errors (e.g. an unreadable entry) as an
994
+ // `error` event, which would otherwise crash the process. Log and keep
995
+ // the remaining watchers running.
996
+ watcher.on("error", (err) => {
997
+ console.error(
998
+ `⚠️ File watcher error for ${dir}: ${err instanceof Error ? err.message : err}`,
999
+ );
1000
+ });
1001
+ watchers.push(watcher);
1002
+ }
933
1003
 
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);
1004
+ for (const file of watchFiles) {
1005
+ const watcher = fsWatch(file, () => onChange(file));
1006
+ watcher.on("error", (err) => {
1007
+ console.error(
1008
+ `⚠️ File watcher error for ${file}: ${err instanceof Error ? err.message : err}`,
1009
+ );
947
1010
  });
948
1011
  watchers.push(watcher);
949
1012
  }
950
1013
 
951
1014
  console.log("👀 Watching for file changes in:");
952
- console.log(" assistant/, gateway/, credential-executor/, packages/");
1015
+ console.log(" <service>/src, <service>/package.json, <service>/Dockerfile");
1016
+ console.log(" for assistant/, gateway/, credential-executor/, packages/*");
953
1017
  console.log("");
954
1018
 
955
1019
  return () => {
@@ -979,6 +1043,7 @@ export async function hatchDocker(
979
1043
  name: string | null,
980
1044
  watch: boolean = false,
981
1045
  configValues: Record<string, string> = {},
1046
+ flagEnvVars: Record<string, string> = {},
982
1047
  options: HatchDockerOptions = {},
983
1048
  ): Promise<void> {
984
1049
  resetLogFile("hatch.log");
@@ -1258,12 +1323,15 @@ export async function hatchDocker(
1258
1323
  : ownSecret;
1259
1324
 
1260
1325
  emitProgress(4, 6, "Starting containers...");
1326
+ const extraGatewayEnv =
1327
+ Object.keys(flagEnvVars).length > 0 ? flagEnvVars : undefined;
1261
1328
  await startContainers(
1262
1329
  {
1263
1330
  signingKey,
1264
1331
  bootstrapSecret,
1265
1332
  cesServiceToken,
1266
1333
  extraAssistantEnv,
1334
+ extraGatewayEnv,
1267
1335
  gatewayPort,
1268
1336
  imageTags,
1269
1337
  instanceName,