@vellumai/cli 0.6.0 → 0.6.1
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/__tests__/teleport.test.ts +4 -21
- package/src/__tests__/version-compat.test.ts +206 -0
- package/src/commands/backup.ts +1 -15
- package/src/commands/events.ts +146 -0
- package/src/commands/hatch.ts +1 -14
- package/src/commands/message.ts +105 -0
- package/src/commands/restore.ts +1 -21
- package/src/commands/retire.ts +2 -7
- package/src/commands/rollback.ts +8 -37
- package/src/commands/teleport.ts +18 -109
- package/src/commands/upgrade.ts +43 -43
- package/src/index.ts +6 -0
- package/src/lib/arg-utils.ts +13 -0
- package/src/lib/assistant-client.ts +228 -0
- package/src/lib/docker.ts +141 -49
- package/src/lib/hatch-local.ts +5 -2
- package/src/lib/health-check.ts +3 -8
- package/src/lib/ngrok.ts +11 -1
- package/src/lib/platform-client.ts +77 -56
- package/src/lib/upgrade-lifecycle.ts +5 -15
- package/src/lib/version-compat.ts +67 -5
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway client for authenticated requests to a hatched assistant's runtime.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates lockfile reading, guardian-token resolution, and
|
|
5
|
+
* authenticated fetch so callers can simply do:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const client = new AssistantClient(); // active / latest
|
|
9
|
+
* const client = new AssistantClient({ assistantId: "my-bot" }); // by name
|
|
10
|
+
* await client.get("/healthz");
|
|
11
|
+
* await client.post("/messages/", { content: "hi" });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
findAssistantByName,
|
|
17
|
+
getActiveAssistant,
|
|
18
|
+
loadLatestAssistant,
|
|
19
|
+
} from "./assistant-config.js";
|
|
20
|
+
import { GATEWAY_PORT } from "./constants.js";
|
|
21
|
+
import { loadGuardianToken } from "./guardian-token.js";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
24
|
+
const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
|
|
25
|
+
|
|
26
|
+
export interface AssistantClientOpts {
|
|
27
|
+
assistantId?: string;
|
|
28
|
+
/**
|
|
29
|
+
* When provided alongside `orgId`, the client authenticates with a
|
|
30
|
+
* session token instead of a guardian token. The session token is
|
|
31
|
+
* sent as `Authorization: Bearer <sessionToken>` and the org id is
|
|
32
|
+
* sent via the `X-Vellum-Org-Id` header.
|
|
33
|
+
*/
|
|
34
|
+
sessionToken?: string;
|
|
35
|
+
/** Required when `sessionToken` is provided. */
|
|
36
|
+
orgId?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RequestOpts {
|
|
40
|
+
timeout?: number;
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
query?: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class AssistantClient {
|
|
47
|
+
readonly runtimeUrl: string;
|
|
48
|
+
|
|
49
|
+
private readonly _assistantId: string;
|
|
50
|
+
private readonly token: string | undefined;
|
|
51
|
+
private readonly orgId: string | undefined;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolves an assistant entry from the lockfile and loads auth credentials.
|
|
55
|
+
*
|
|
56
|
+
* @param opts.assistantId - Explicit assistant name. When omitted, the
|
|
57
|
+
* active assistant is used, falling back to the most recently hatched one.
|
|
58
|
+
* @throws If no matching assistant is found.
|
|
59
|
+
*/
|
|
60
|
+
constructor(opts?: AssistantClientOpts) {
|
|
61
|
+
const nameOrId = opts?.assistantId;
|
|
62
|
+
let entry = nameOrId ? findAssistantByName(nameOrId) : null;
|
|
63
|
+
|
|
64
|
+
if (nameOrId && !entry) {
|
|
65
|
+
throw new Error(`No assistant found with name '${nameOrId}'.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!entry) {
|
|
69
|
+
const active = getActiveAssistant();
|
|
70
|
+
if (active) {
|
|
71
|
+
entry = findAssistantByName(active);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!entry) {
|
|
76
|
+
entry = loadLatestAssistant();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!entry) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
"No assistant found. Hatch one first with 'vellum hatch'.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.runtimeUrl = (
|
|
86
|
+
entry.localUrl ||
|
|
87
|
+
entry.runtimeUrl ||
|
|
88
|
+
FALLBACK_RUNTIME_URL
|
|
89
|
+
).replace(/\/+$/, "");
|
|
90
|
+
this._assistantId = entry.assistantId;
|
|
91
|
+
|
|
92
|
+
if (opts?.sessionToken) {
|
|
93
|
+
// Platform assistant: use session token + org id header.
|
|
94
|
+
this.token = opts.sessionToken;
|
|
95
|
+
this.orgId = opts.orgId;
|
|
96
|
+
} else {
|
|
97
|
+
this.token =
|
|
98
|
+
loadGuardianToken(this._assistantId)?.accessToken ?? entry.bearerToken;
|
|
99
|
+
this.orgId = undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** GET request to the gateway. Auth headers are added automatically. */
|
|
104
|
+
async get(urlPath: string, opts?: RequestOpts): Promise<Response> {
|
|
105
|
+
return this.request("GET", urlPath, undefined, opts);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Subscribe to an SSE endpoint and yield parsed JSON objects from `data:` lines.
|
|
110
|
+
* Automatically sets `Accept: text/event-stream` and skips heartbeat comments.
|
|
111
|
+
*/
|
|
112
|
+
async *stream<T = unknown>(
|
|
113
|
+
urlPath: string,
|
|
114
|
+
opts?: RequestOpts,
|
|
115
|
+
): AsyncGenerator<T> {
|
|
116
|
+
const response = await this.get(urlPath, {
|
|
117
|
+
...opts,
|
|
118
|
+
headers: { Accept: "text/event-stream", ...opts?.headers },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
const body = await response.text().catch(() => "");
|
|
123
|
+
throw new Error(
|
|
124
|
+
`HTTP ${response.status}: ${body || response.statusText}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!response.body) {
|
|
129
|
+
throw new Error("No response body received.");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const decoder = new TextDecoder();
|
|
133
|
+
let buffer = "";
|
|
134
|
+
|
|
135
|
+
for await (const chunk of response.body) {
|
|
136
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
137
|
+
|
|
138
|
+
let boundary: number;
|
|
139
|
+
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
|
|
140
|
+
const frame = buffer.slice(0, boundary);
|
|
141
|
+
buffer = buffer.slice(boundary + 2);
|
|
142
|
+
|
|
143
|
+
if (!frame.trim() || frame.startsWith(":")) continue;
|
|
144
|
+
|
|
145
|
+
let data: string | undefined;
|
|
146
|
+
for (const line of frame.split("\n")) {
|
|
147
|
+
if (line.startsWith("data: ")) {
|
|
148
|
+
data = line.slice(6);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!data) continue;
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
yield JSON.parse(data) as T;
|
|
156
|
+
} catch {
|
|
157
|
+
// Skip malformed JSON
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** POST request to the gateway with a JSON body. Auth headers are added automatically. */
|
|
164
|
+
async post(
|
|
165
|
+
urlPath: string,
|
|
166
|
+
body: unknown,
|
|
167
|
+
opts?: RequestOpts,
|
|
168
|
+
): Promise<Response> {
|
|
169
|
+
return this.request("POST", urlPath, body, opts);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** PATCH request to the gateway with a JSON body. Auth headers are added automatically. */
|
|
173
|
+
async patch(
|
|
174
|
+
urlPath: string,
|
|
175
|
+
body: unknown,
|
|
176
|
+
opts?: RequestOpts,
|
|
177
|
+
): Promise<Response> {
|
|
178
|
+
return this.request("PATCH", urlPath, body, opts);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async request(
|
|
182
|
+
method: string,
|
|
183
|
+
urlPath: string,
|
|
184
|
+
body: unknown | undefined,
|
|
185
|
+
opts?: RequestOpts,
|
|
186
|
+
): Promise<Response> {
|
|
187
|
+
const qs = opts?.query
|
|
188
|
+
? `?${new URLSearchParams(opts.query).toString()}`
|
|
189
|
+
: "";
|
|
190
|
+
const url = `${this.runtimeUrl}/v1/assistants/${this._assistantId}${urlPath}${qs}`;
|
|
191
|
+
|
|
192
|
+
const headers: Record<string, string> = { ...opts?.headers };
|
|
193
|
+
if (this.token) {
|
|
194
|
+
headers["Authorization"] ??= `Bearer ${this.token}`;
|
|
195
|
+
}
|
|
196
|
+
if (this.orgId) {
|
|
197
|
+
headers["X-Vellum-Org-Id"] ??= this.orgId;
|
|
198
|
+
}
|
|
199
|
+
if (body !== undefined) {
|
|
200
|
+
headers["Content-Type"] = "application/json";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const jsonBody = body !== undefined ? JSON.stringify(body) : undefined;
|
|
204
|
+
|
|
205
|
+
if (opts?.signal) {
|
|
206
|
+
return fetch(url, {
|
|
207
|
+
method,
|
|
208
|
+
headers,
|
|
209
|
+
body: jsonBody,
|
|
210
|
+
signal: opts.signal,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
217
|
+
try {
|
|
218
|
+
return await fetch(url, {
|
|
219
|
+
method,
|
|
220
|
+
headers,
|
|
221
|
+
body: jsonBody,
|
|
222
|
+
signal: controller.signal,
|
|
223
|
+
});
|
|
224
|
+
} finally {
|
|
225
|
+
clearTimeout(timeoutId);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/lib/docker.ts
CHANGED
|
@@ -92,51 +92,60 @@ async function downloadAndExtract(
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
* Installs Docker CLI
|
|
96
|
-
* directly into ~/.
|
|
97
|
-
*
|
|
98
|
-
* Falls back to Homebrew if available (e.g. admin users who prefer it).
|
|
95
|
+
* Installs Docker CLI (and Colima + Lima on macOS) by downloading pre-built
|
|
96
|
+
* binaries directly into ~/.local/bin/. No Homebrew or sudo required.
|
|
99
97
|
*/
|
|
100
98
|
async function installDockerToolchain(): Promise<void> {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// brew not found
|
|
108
|
-
}
|
|
99
|
+
const isMac = platform() === "darwin";
|
|
100
|
+
const isLinux = platform() === "linux";
|
|
101
|
+
|
|
102
|
+
mkdirSync(LOCAL_BIN_DIR, { recursive: true });
|
|
103
|
+
|
|
104
|
+
const cpuArch = releaseArch();
|
|
109
105
|
|
|
110
|
-
if (
|
|
111
|
-
|
|
106
|
+
if (isLinux) {
|
|
107
|
+
// On Linux, Docker runs natively — only need the Docker CLI.
|
|
108
|
+
console.log(
|
|
109
|
+
"🐳 Docker not found. Installing Docker CLI to ~/.local/bin/...",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const dockerArch = cpuArch === "aarch64" ? "aarch64" : "x86_64";
|
|
113
|
+
const dockerTarUrl = `https://download.docker.com/linux/static/stable/${dockerArch}/docker-27.5.1.tgz`;
|
|
114
|
+
const dockerTmpDir = join(LOCAL_BIN_DIR, ".docker-tmp");
|
|
115
|
+
mkdirSync(dockerTmpDir, { recursive: true });
|
|
112
116
|
try {
|
|
113
|
-
await
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
);
|
|
117
|
+
await downloadAndExtract(dockerTarUrl, dockerTmpDir, "Docker CLI");
|
|
118
|
+
await exec("mv", [
|
|
119
|
+
join(dockerTmpDir, "docker", "docker"),
|
|
120
|
+
join(LOCAL_BIN_DIR, "docker"),
|
|
121
|
+
]);
|
|
122
|
+
chmodSync(join(LOCAL_BIN_DIR, "docker"), 0o755);
|
|
123
|
+
} finally {
|
|
124
|
+
await exec("rm", ["-rf", dockerTmpDir]).catch(() => {});
|
|
119
125
|
}
|
|
120
|
-
}
|
|
121
126
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
mkdirSync(LOCAL_BIN_DIR, { recursive: true });
|
|
127
|
+
if (!existsSync(join(LOCAL_BIN_DIR, "docker"))) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"docker binary not found after installation. Please install Docker manually: https://docs.docker.com/engine/install/",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
128
132
|
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
console.log(" ✅ Docker CLI installed to ~/.local/bin/");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
131
136
|
|
|
132
137
|
if (!isMac) {
|
|
133
138
|
throw new Error(
|
|
134
|
-
"Automatic Docker installation is only supported on macOS. " +
|
|
139
|
+
"Automatic Docker installation is only supported on macOS and Linux. " +
|
|
135
140
|
"Please install Docker manually: https://docs.docker.com/engine/install/",
|
|
136
141
|
);
|
|
137
142
|
}
|
|
138
143
|
|
|
139
|
-
|
|
144
|
+
console.log(
|
|
145
|
+
"🐳 Docker not found. Installing Docker, Colima, and Lima to ~/.local/bin/...",
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// --- Docker CLI (macOS) ---
|
|
140
149
|
// Docker publishes static binaries at download.docker.com.
|
|
141
150
|
const dockerArch = cpuArch === "aarch64" ? "aarch64" : "x86_64";
|
|
142
151
|
const dockerTarUrl = `https://download.docker.com/mac/static/stable/${dockerArch}/docker-27.5.1.tgz`;
|
|
@@ -223,13 +232,17 @@ async function ensureDockerInstalled(): Promise<void> {
|
|
|
223
232
|
// Always add ~/.local/bin to PATH so previously installed binaries are found.
|
|
224
233
|
ensureLocalBinOnPath();
|
|
225
234
|
|
|
226
|
-
|
|
227
|
-
|
|
235
|
+
const isLinux = platform() === "linux";
|
|
236
|
+
|
|
237
|
+
// On Linux, Docker runs natively — only need the docker CLI + daemon.
|
|
238
|
+
// On macOS, we also need Colima and Lima to provide a Linux VM.
|
|
228
239
|
const toolchainComplete = await (async () => {
|
|
229
240
|
try {
|
|
230
241
|
await execOutput("docker", ["--version"]);
|
|
231
|
-
|
|
232
|
-
|
|
242
|
+
if (!isLinux) {
|
|
243
|
+
await execOutput("colima", ["version"]);
|
|
244
|
+
await execOutput("limactl", ["--version"]);
|
|
245
|
+
}
|
|
233
246
|
return true;
|
|
234
247
|
} catch {
|
|
235
248
|
return false;
|
|
@@ -251,10 +264,19 @@ async function ensureDockerInstalled(): Promise<void> {
|
|
|
251
264
|
}
|
|
252
265
|
}
|
|
253
266
|
|
|
254
|
-
// Verify the Docker daemon is reachable
|
|
267
|
+
// Verify the Docker daemon is reachable.
|
|
255
268
|
try {
|
|
256
269
|
await exec("docker", ["info"]);
|
|
257
270
|
} catch {
|
|
271
|
+
// On Linux, the daemon must already be running (systemd, etc.).
|
|
272
|
+
if (isLinux) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
"Docker daemon is not running. Please start it with 'sudo systemctl start docker' " +
|
|
275
|
+
"or ensure the Docker service is enabled.",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// On macOS, try starting Colima.
|
|
258
280
|
let hasColima = false;
|
|
259
281
|
try {
|
|
260
282
|
await execOutput("colima", ["version"]);
|
|
@@ -394,6 +416,15 @@ function walkUpForRepoRoot(startDir: string): string | undefined {
|
|
|
394
416
|
return undefined;
|
|
395
417
|
}
|
|
396
418
|
|
|
419
|
+
/**
|
|
420
|
+
* Returns `true` when the given root looks like a full source checkout
|
|
421
|
+
* (has assistant source code), as opposed to a packaged `.app` bundle
|
|
422
|
+
* that only contains the Dockerfiles.
|
|
423
|
+
*/
|
|
424
|
+
function hasFullSourceTree(root: string): boolean {
|
|
425
|
+
return existsSync(join(root, "assistant", "package.json"));
|
|
426
|
+
}
|
|
427
|
+
|
|
397
428
|
/**
|
|
398
429
|
* Locate the repository root by walking up from `cli/src/lib/` until we
|
|
399
430
|
* find a directory containing the expected Dockerfiles.
|
|
@@ -414,6 +445,20 @@ function findRepoRoot(): string {
|
|
|
414
445
|
return execRoot;
|
|
415
446
|
}
|
|
416
447
|
|
|
448
|
+
// Check the app bundle's Resources directory. Debug DMG builds bundle
|
|
449
|
+
// Dockerfiles at Contents/Resources/dockerfiles/{assistant,gateway,...}/Dockerfile.
|
|
450
|
+
// The CLI binary lives at Contents/MacOS/vellum-cli, so Resources is at
|
|
451
|
+
// ../Resources relative to the binary.
|
|
452
|
+
const bundledRoot = join(
|
|
453
|
+
dirname(process.execPath),
|
|
454
|
+
"..",
|
|
455
|
+
"Resources",
|
|
456
|
+
"dockerfiles",
|
|
457
|
+
);
|
|
458
|
+
if (existsSync(join(bundledRoot, "assistant", "Dockerfile"))) {
|
|
459
|
+
return bundledRoot;
|
|
460
|
+
}
|
|
461
|
+
|
|
417
462
|
// Walk up from cwd as a final fallback
|
|
418
463
|
const cwdRoot = walkUpForRepoRoot(process.cwd());
|
|
419
464
|
if (cwdRoot) {
|
|
@@ -691,11 +736,13 @@ export async function sleepContainers(
|
|
|
691
736
|
}
|
|
692
737
|
}
|
|
693
738
|
|
|
694
|
-
/** Start existing stopped containers, starting Colima first if it isn't running. */
|
|
739
|
+
/** Start existing stopped containers, starting Colima first if it isn't running (macOS only). */
|
|
695
740
|
export async function wakeContainers(
|
|
696
741
|
res: ReturnType<typeof dockerResourceNames>,
|
|
697
742
|
): Promise<void> {
|
|
698
|
-
|
|
743
|
+
if (platform() !== "linux") {
|
|
744
|
+
await ensureColimaRunning();
|
|
745
|
+
}
|
|
699
746
|
for (const container of [
|
|
700
747
|
res.assistantContainer,
|
|
701
748
|
res.gatewayContainer,
|
|
@@ -805,6 +852,9 @@ function affectedServices(
|
|
|
805
852
|
* images and restart their containers.
|
|
806
853
|
*/
|
|
807
854
|
function startFileWatcher(opts: {
|
|
855
|
+
signingKey?: string;
|
|
856
|
+
bootstrapSecret?: string;
|
|
857
|
+
cesServiceToken?: string;
|
|
808
858
|
gatewayPort: number;
|
|
809
859
|
imageTags: Record<ServiceName, string>;
|
|
810
860
|
instanceName: string;
|
|
@@ -826,6 +876,9 @@ function startFileWatcher(opts: {
|
|
|
826
876
|
|
|
827
877
|
const configs = serviceImageConfigs(repoRoot, imageTags);
|
|
828
878
|
const runArgs = serviceDockerRunArgs({
|
|
879
|
+
signingKey: opts.signingKey,
|
|
880
|
+
bootstrapSecret: opts.bootstrapSecret,
|
|
881
|
+
cesServiceToken: opts.cesServiceToken,
|
|
829
882
|
gatewayPort,
|
|
830
883
|
imageTags,
|
|
831
884
|
instanceName,
|
|
@@ -963,8 +1016,23 @@ export async function hatchDocker(
|
|
|
963
1016
|
let repoRoot: string | undefined;
|
|
964
1017
|
|
|
965
1018
|
if (watch) {
|
|
966
|
-
emitProgress(2, 6, "Building images...");
|
|
967
1019
|
repoRoot = findRepoRoot();
|
|
1020
|
+
|
|
1021
|
+
// When running from a packaged .app bundle, the Dockerfiles are
|
|
1022
|
+
// present (so findRepoRoot succeeds) but the full source tree is
|
|
1023
|
+
// not — we can't build images locally. Fall back to pulling
|
|
1024
|
+
// pre-built images instead.
|
|
1025
|
+
if (!hasFullSourceTree(repoRoot)) {
|
|
1026
|
+
log(
|
|
1027
|
+
"⚠️ Dockerfiles found but no source tree — falling back to image pull",
|
|
1028
|
+
);
|
|
1029
|
+
watch = false;
|
|
1030
|
+
repoRoot = undefined;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (watch && repoRoot) {
|
|
1035
|
+
emitProgress(2, 6, "Building images...");
|
|
968
1036
|
const localTag = `local-${instanceName}`;
|
|
969
1037
|
imageTags.assistant = `vellum-assistant:${localTag}`;
|
|
970
1038
|
imageTags.gateway = `vellum-gateway:${localTag}`;
|
|
@@ -983,20 +1051,41 @@ export async function hatchDocker(
|
|
|
983
1051
|
|
|
984
1052
|
await buildAllImages(repoRoot, imageTags, log);
|
|
985
1053
|
log("✅ Docker images built");
|
|
986
|
-
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (!watch || !repoRoot) {
|
|
987
1057
|
emitProgress(2, 6, "Pulling images...");
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1058
|
+
|
|
1059
|
+
// Allow explicit image overrides via environment variables.
|
|
1060
|
+
// When all three are set, skip version-based resolution entirely.
|
|
1061
|
+
const envAssistant = process.env.VELLUM_ASSISTANT_IMAGE;
|
|
1062
|
+
const envGateway = process.env.VELLUM_GATEWAY_IMAGE;
|
|
1063
|
+
const envCredentialExecutor =
|
|
1064
|
+
process.env.VELLUM_CREDENTIAL_EXECUTOR_IMAGE;
|
|
1065
|
+
|
|
1066
|
+
let imageSource: string;
|
|
1067
|
+
|
|
1068
|
+
if (envAssistant && envGateway && envCredentialExecutor) {
|
|
1069
|
+
imageTags.assistant = envAssistant;
|
|
1070
|
+
imageTags.gateway = envGateway;
|
|
1071
|
+
imageTags["credential-executor"] = envCredentialExecutor;
|
|
1072
|
+
imageSource = "env override";
|
|
1073
|
+
log("Using image overrides from environment variables");
|
|
1074
|
+
} else {
|
|
1075
|
+
const version = cliPkg.version;
|
|
1076
|
+
const versionTag = version ? `v${version}` : "latest";
|
|
1077
|
+
log("🔍 Resolving image references...");
|
|
1078
|
+
const resolved = await resolveImageRefs(versionTag, log);
|
|
1079
|
+
imageTags.assistant = resolved.imageTags.assistant;
|
|
1080
|
+
imageTags.gateway = resolved.imageTags.gateway;
|
|
1081
|
+
imageTags["credential-executor"] =
|
|
1082
|
+
resolved.imageTags["credential-executor"];
|
|
1083
|
+
imageSource = resolved.source;
|
|
1084
|
+
}
|
|
996
1085
|
|
|
997
1086
|
log(`🥚 Hatching Docker assistant: ${instanceName}`);
|
|
998
1087
|
log(` Species: ${species}`);
|
|
999
|
-
log(` Images (${
|
|
1088
|
+
log(` Images (${imageSource}):`);
|
|
1000
1089
|
log(` assistant: ${imageTags.assistant}`);
|
|
1001
1090
|
log(` gateway: ${imageTags.gateway}`);
|
|
1002
1091
|
log(` credential-executor: ${imageTags["credential-executor"]}`);
|
|
@@ -1106,6 +1195,9 @@ export async function hatchDocker(
|
|
|
1106
1195
|
saveAssistantEntry({ ...dockerEntry, watcherPid: process.pid });
|
|
1107
1196
|
|
|
1108
1197
|
const stopWatcher = startFileWatcher({
|
|
1198
|
+
signingKey,
|
|
1199
|
+
bootstrapSecret,
|
|
1200
|
+
cesServiceToken,
|
|
1109
1201
|
gatewayPort,
|
|
1110
1202
|
imageTags,
|
|
1111
1203
|
instanceName,
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -308,10 +308,13 @@ export async function hatchLocal(
|
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
// Lease a guardian token so the desktop app can import it on first launch
|
|
311
|
-
// instead of hitting /v1/guardian/init itself.
|
|
311
|
+
// instead of hitting /v1/guardian/init itself. Use loopback to satisfy
|
|
312
|
+
// the daemon's local-only check — the mDNS runtimeUrl resolves to a LAN
|
|
313
|
+
// IP which the daemon rejects as non-loopback.
|
|
312
314
|
emitProgress(6, 7, "Securing connection...");
|
|
315
|
+
const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
313
316
|
try {
|
|
314
|
-
await leaseGuardianToken(
|
|
317
|
+
await leaseGuardianToken(loopbackUrl, instanceName);
|
|
315
318
|
} catch (err) {
|
|
316
319
|
console.error(`⚠️ Guardian token lease failed: ${err}`);
|
|
317
320
|
}
|
package/src/lib/health-check.ts
CHANGED
|
@@ -25,10 +25,10 @@ export async function checkManagedHealth(
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
let
|
|
28
|
+
let headers: Record<string, string>;
|
|
29
29
|
try {
|
|
30
|
-
const {
|
|
31
|
-
|
|
30
|
+
const { authHeaders } = await import("./platform-client.js");
|
|
31
|
+
headers = await authHeaders(token, runtimeUrl);
|
|
32
32
|
} catch (err) {
|
|
33
33
|
return {
|
|
34
34
|
status: "error (auth)",
|
|
@@ -44,11 +44,6 @@ export async function checkManagedHealth(
|
|
|
44
44
|
HEALTH_CHECK_TIMEOUT_MS,
|
|
45
45
|
);
|
|
46
46
|
|
|
47
|
-
const headers: Record<string, string> = {
|
|
48
|
-
"X-Session-Token": token,
|
|
49
|
-
"Vellum-Organization-Id": orgId,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
47
|
const response = await fetch(url, {
|
|
53
48
|
signal: controller.signal,
|
|
54
49
|
headers,
|
package/src/lib/ngrok.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import {
|
|
3
|
+
closeSync,
|
|
3
4
|
existsSync,
|
|
4
5
|
mkdirSync,
|
|
5
6
|
openSync,
|
|
@@ -130,10 +131,11 @@ export function startNgrokProcess(
|
|
|
130
131
|
logFilePath?: string,
|
|
131
132
|
): ChildProcess {
|
|
132
133
|
let stdio: ("ignore" | "pipe" | number)[] = ["ignore", "pipe", "pipe"];
|
|
134
|
+
let fd: number | undefined;
|
|
133
135
|
if (logFilePath) {
|
|
134
136
|
const dir = dirname(logFilePath);
|
|
135
137
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
136
|
-
|
|
138
|
+
fd = openSync(logFilePath, "a");
|
|
137
139
|
stdio = ["ignore", fd, fd];
|
|
138
140
|
}
|
|
139
141
|
|
|
@@ -141,6 +143,14 @@ export function startNgrokProcess(
|
|
|
141
143
|
detached: true,
|
|
142
144
|
stdio,
|
|
143
145
|
});
|
|
146
|
+
|
|
147
|
+
// The child process inherits a duplicate of the fd via dup2, so the
|
|
148
|
+
// parent's copy is no longer needed. Close it to avoid leaking the
|
|
149
|
+
// file descriptor for the lifetime of the parent process.
|
|
150
|
+
if (fd !== undefined) {
|
|
151
|
+
closeSync(fd);
|
|
152
|
+
}
|
|
153
|
+
|
|
144
154
|
return child;
|
|
145
155
|
}
|
|
146
156
|
|