@vellumai/cli 0.8.8 → 0.8.9-dev.202606091853.fbaa2ae
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/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +88 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +15 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +33 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-client-refresh.test.ts +65 -4
- package/src/__tests__/client-tui-refresh.test.ts +50 -6
- package/src/__tests__/guardian-token.test.ts +130 -4
- package/src/__tests__/message.test.ts +86 -0
- package/src/__tests__/teleport.test.ts +1 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +68 -9
- package/src/commands/client.ts +100 -58
- package/src/commands/hatch.ts +14 -4
- package/src/commands/login.ts +128 -9
- package/src/commands/message.ts +109 -19
- package/src/commands/teleport.ts +2 -0
- package/src/components/DefaultMainScreen.tsx +27 -2
- package/src/lib/__tests__/docker.test.ts +99 -0
- package/src/lib/assistant-client.ts +31 -13
- package/src/lib/docker.ts +97 -29
- package/src/lib/flag-args.test.ts +89 -0
- package/src/lib/flag-args.ts +74 -0
- package/src/lib/guardian-token.ts +54 -0
- package/src/lib/hatch-local.ts +2 -0
- package/src/lib/local.ts +6 -1
- package/src/lib/runtime-url.ts +90 -0
- package/src/lib/statefulset.ts +9 -0
package/src/commands/message.ts
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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,
|
|
64
|
+
const [conversationKey, afterConversationKey] = extractFlag(
|
|
46
65
|
args,
|
|
47
66
|
"--conversation-key",
|
|
48
67
|
);
|
|
49
|
-
args =
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
value: { conversationKey, jsonOutput, inlineMessage: args[0] },
|
|
113
|
+
};
|
|
61
114
|
}
|
|
62
115
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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 });
|
package/src/commands/teleport.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
223
|
-
//
|
|
224
|
-
// and
|
|
225
|
-
//
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
825
|
-
* and packages
|
|
826
|
-
*
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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("
|
|
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,
|