@vellumai/cli 0.5.16 ā 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/bun.lock +46 -52
- package/package.json +1 -1
- package/src/__tests__/teleport.test.ts +430 -4
- 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/message.ts +105 -0
- package/src/commands/restore.ts +1 -21
- package/src/commands/retire.ts +2 -7
- package/src/commands/rollback.ts +14 -37
- package/src/commands/teleport.ts +125 -65
- package/src/commands/upgrade.ts +50 -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/aws.ts +2 -1
- package/src/lib/constants.ts +0 -11
- package/src/lib/docker.ts +168 -62
- package/src/lib/gcp.ts +2 -5
- 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 +191 -36
- package/src/lib/upgrade-lifecycle.ts +13 -15
- package/src/lib/version-compat.ts +67 -5
- package/src/shared/provider-env-vars.ts +19 -0
|
@@ -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/aws.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { buildStartupScript, watchHatching } from "../commands/hatch";
|
|
|
6
6
|
import type { PollResult } from "../commands/hatch";
|
|
7
7
|
import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
|
|
8
8
|
import type { AssistantEntry } from "./assistant-config";
|
|
9
|
-
import { GATEWAY_PORT
|
|
9
|
+
import { GATEWAY_PORT } from "./constants";
|
|
10
|
+
import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
10
11
|
import type { Species } from "./constants";
|
|
11
12
|
import { leaseGuardianToken } from "./guardian-token";
|
|
12
13
|
import { generateInstanceName } from "./random-name";
|
package/src/lib/constants.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import providerEnvVarsRegistry from "../../../meta/provider-env-vars.json";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Canonical internal assistant ID used as the default/fallback across the CLI
|
|
5
3
|
* and daemon. Mirrors `DAEMON_INTERNAL_ASSISTANT_ID` from
|
|
@@ -28,15 +26,6 @@ export const LOCKFILE_NAMES = [
|
|
|
28
26
|
".vellum.lockfile.json",
|
|
29
27
|
] as const;
|
|
30
28
|
|
|
31
|
-
/**
|
|
32
|
-
* Environment variable names for provider API keys, keyed by provider ID.
|
|
33
|
-
* Loaded from the shared registry at `meta/provider-env-vars.json` ā the
|
|
34
|
-
* single source of truth also consumed by the assistant runtime and the
|
|
35
|
-
* macOS client.
|
|
36
|
-
*/
|
|
37
|
-
export const PROVIDER_ENV_VAR_NAMES: Record<string, string> =
|
|
38
|
-
providerEnvVarsRegistry.providers;
|
|
39
|
-
|
|
40
29
|
export const VALID_REMOTE_HOSTS = [
|
|
41
30
|
"local",
|
|
42
31
|
"gcp",
|
package/src/lib/docker.ts
CHANGED
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
} from "./assistant-config";
|
|
14
14
|
import type { AssistantEntry } from "./assistant-config";
|
|
15
15
|
import { writeInitialConfig } from "./config-utils";
|
|
16
|
-
import { DEFAULT_GATEWAY_PORT
|
|
16
|
+
import { DEFAULT_GATEWAY_PORT } from "./constants";
|
|
17
|
+
import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
17
18
|
import type { Species } from "./constants";
|
|
18
19
|
import { leaseGuardianToken } from "./guardian-token";
|
|
19
20
|
import { isVellumProcess, stopProcess } from "./process";
|
|
@@ -91,51 +92,60 @@ async function downloadAndExtract(
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/**
|
|
94
|
-
* Installs Docker CLI
|
|
95
|
-
* directly into ~/.
|
|
96
|
-
*
|
|
97
|
-
* 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.
|
|
98
97
|
*/
|
|
99
98
|
async function installDockerToolchain(): Promise<void> {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// brew not found
|
|
107
|
-
}
|
|
99
|
+
const isMac = platform() === "darwin";
|
|
100
|
+
const isLinux = platform() === "linux";
|
|
101
|
+
|
|
102
|
+
mkdirSync(LOCAL_BIN_DIR, { recursive: true });
|
|
103
|
+
|
|
104
|
+
const cpuArch = releaseArch();
|
|
108
105
|
|
|
109
|
-
if (
|
|
110
|
-
|
|
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 });
|
|
111
116
|
try {
|
|
112
|
-
await
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
);
|
|
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(() => {});
|
|
118
125
|
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Direct binary install ā no sudo required.
|
|
122
|
-
console.log(
|
|
123
|
-
"š³ Docker not found. Installing Docker, Colima, and Lima to ~/.local/bin/...",
|
|
124
|
-
);
|
|
125
126
|
|
|
126
|
-
|
|
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
|
+
}
|
|
127
132
|
|
|
128
|
-
|
|
129
|
-
|
|
133
|
+
console.log(" ā
Docker CLI installed to ~/.local/bin/");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
130
136
|
|
|
131
137
|
if (!isMac) {
|
|
132
138
|
throw new Error(
|
|
133
|
-
"Automatic Docker installation is only supported on macOS. " +
|
|
139
|
+
"Automatic Docker installation is only supported on macOS and Linux. " +
|
|
134
140
|
"Please install Docker manually: https://docs.docker.com/engine/install/",
|
|
135
141
|
);
|
|
136
142
|
}
|
|
137
143
|
|
|
138
|
-
|
|
144
|
+
console.log(
|
|
145
|
+
"š³ Docker not found. Installing Docker, Colima, and Lima to ~/.local/bin/...",
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// --- Docker CLI (macOS) ---
|
|
139
149
|
// Docker publishes static binaries at download.docker.com.
|
|
140
150
|
const dockerArch = cpuArch === "aarch64" ? "aarch64" : "x86_64";
|
|
141
151
|
const dockerTarUrl = `https://download.docker.com/mac/static/stable/${dockerArch}/docker-27.5.1.tgz`;
|
|
@@ -222,13 +232,17 @@ async function ensureDockerInstalled(): Promise<void> {
|
|
|
222
232
|
// Always add ~/.local/bin to PATH so previously installed binaries are found.
|
|
223
233
|
ensureLocalBinOnPath();
|
|
224
234
|
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
227
239
|
const toolchainComplete = await (async () => {
|
|
228
240
|
try {
|
|
229
241
|
await execOutput("docker", ["--version"]);
|
|
230
|
-
|
|
231
|
-
|
|
242
|
+
if (!isLinux) {
|
|
243
|
+
await execOutput("colima", ["version"]);
|
|
244
|
+
await execOutput("limactl", ["--version"]);
|
|
245
|
+
}
|
|
232
246
|
return true;
|
|
233
247
|
} catch {
|
|
234
248
|
return false;
|
|
@@ -250,10 +264,19 @@ async function ensureDockerInstalled(): Promise<void> {
|
|
|
250
264
|
}
|
|
251
265
|
}
|
|
252
266
|
|
|
253
|
-
// Verify the Docker daemon is reachable
|
|
267
|
+
// Verify the Docker daemon is reachable.
|
|
254
268
|
try {
|
|
255
269
|
await exec("docker", ["info"]);
|
|
256
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.
|
|
257
280
|
let hasColima = false;
|
|
258
281
|
try {
|
|
259
282
|
await execOutput("colima", ["version"]);
|
|
@@ -393,6 +416,15 @@ function walkUpForRepoRoot(startDir: string): string | undefined {
|
|
|
393
416
|
return undefined;
|
|
394
417
|
}
|
|
395
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
|
+
|
|
396
428
|
/**
|
|
397
429
|
* Locate the repository root by walking up from `cli/src/lib/` until we
|
|
398
430
|
* find a directory containing the expected Dockerfiles.
|
|
@@ -413,6 +445,20 @@ function findRepoRoot(): string {
|
|
|
413
445
|
return execRoot;
|
|
414
446
|
}
|
|
415
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
|
+
|
|
416
462
|
// Walk up from cwd as a final fallback
|
|
417
463
|
const cwdRoot = walkUpForRepoRoot(process.cwd());
|
|
418
464
|
if (cwdRoot) {
|
|
@@ -479,8 +525,9 @@ async function buildAllImages(
|
|
|
479
525
|
|
|
480
526
|
/**
|
|
481
527
|
* Returns a function that builds the `docker run` arguments for a given
|
|
482
|
-
* service.
|
|
483
|
-
*
|
|
528
|
+
* service. All three containers share a network namespace via
|
|
529
|
+
* `--network=container:` so inter-service traffic is over localhost,
|
|
530
|
+
* matching the platform's Kubernetes pod topology.
|
|
484
531
|
*/
|
|
485
532
|
export function serviceDockerRunArgs(opts: {
|
|
486
533
|
signingKey?: string;
|
|
@@ -511,12 +558,14 @@ export function serviceDockerRunArgs(opts: {
|
|
|
511
558
|
"--name",
|
|
512
559
|
res.assistantContainer,
|
|
513
560
|
`--network=${res.network}`,
|
|
561
|
+
"-p",
|
|
562
|
+
`${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
|
|
514
563
|
"-v",
|
|
515
564
|
`${res.workspaceVolume}:/workspace`,
|
|
516
565
|
"-v",
|
|
517
566
|
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
518
567
|
"-e",
|
|
519
|
-
"IS_CONTAINERIZED=
|
|
568
|
+
"IS_CONTAINERIZED=true",
|
|
520
569
|
"-e",
|
|
521
570
|
`VELLUM_ASSISTANT_NAME=${instanceName}`,
|
|
522
571
|
"-e",
|
|
@@ -526,9 +575,9 @@ export function serviceDockerRunArgs(opts: {
|
|
|
526
575
|
"-e",
|
|
527
576
|
"VELLUM_WORKSPACE_DIR=/workspace",
|
|
528
577
|
"-e",
|
|
529
|
-
|
|
578
|
+
"CES_CREDENTIAL_URL=http://localhost:8090",
|
|
530
579
|
"-e",
|
|
531
|
-
`GATEWAY_INTERNAL_URL=http
|
|
580
|
+
`GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
|
|
532
581
|
];
|
|
533
582
|
if (defaultWorkspaceConfigPath) {
|
|
534
583
|
const containerPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
|
|
@@ -567,9 +616,7 @@ export function serviceDockerRunArgs(opts: {
|
|
|
567
616
|
"-d",
|
|
568
617
|
"--name",
|
|
569
618
|
res.gatewayContainer,
|
|
570
|
-
`--network
|
|
571
|
-
"-p",
|
|
572
|
-
`${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
|
|
619
|
+
`--network=container:${res.assistantContainer}`,
|
|
573
620
|
"-v",
|
|
574
621
|
`${res.workspaceVolume}:/workspace`,
|
|
575
622
|
"-v",
|
|
@@ -581,13 +628,13 @@ export function serviceDockerRunArgs(opts: {
|
|
|
581
628
|
"-e",
|
|
582
629
|
`GATEWAY_PORT=${GATEWAY_INTERNAL_PORT}`,
|
|
583
630
|
"-e",
|
|
584
|
-
|
|
631
|
+
"ASSISTANT_HOST=localhost",
|
|
585
632
|
"-e",
|
|
586
633
|
`RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
|
|
587
634
|
"-e",
|
|
588
635
|
"RUNTIME_PROXY_ENABLED=true",
|
|
589
636
|
"-e",
|
|
590
|
-
|
|
637
|
+
"CES_CREDENTIAL_URL=http://localhost:8090",
|
|
591
638
|
...(cesServiceToken
|
|
592
639
|
? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
|
|
593
640
|
: []),
|
|
@@ -605,7 +652,7 @@ export function serviceDockerRunArgs(opts: {
|
|
|
605
652
|
"-d",
|
|
606
653
|
"--name",
|
|
607
654
|
res.cesContainer,
|
|
608
|
-
`--network
|
|
655
|
+
`--network=container:${res.assistantContainer}`,
|
|
609
656
|
"-v",
|
|
610
657
|
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
611
658
|
"-v",
|
|
@@ -689,11 +736,13 @@ export async function sleepContainers(
|
|
|
689
736
|
}
|
|
690
737
|
}
|
|
691
738
|
|
|
692
|
-
/** 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). */
|
|
693
740
|
export async function wakeContainers(
|
|
694
741
|
res: ReturnType<typeof dockerResourceNames>,
|
|
695
742
|
): Promise<void> {
|
|
696
|
-
|
|
743
|
+
if (platform() !== "linux") {
|
|
744
|
+
await ensureColimaRunning();
|
|
745
|
+
}
|
|
697
746
|
for (const container of [
|
|
698
747
|
res.assistantContainer,
|
|
699
748
|
res.gatewayContainer,
|
|
@@ -803,6 +852,9 @@ function affectedServices(
|
|
|
803
852
|
* images and restart their containers.
|
|
804
853
|
*/
|
|
805
854
|
function startFileWatcher(opts: {
|
|
855
|
+
signingKey?: string;
|
|
856
|
+
bootstrapSecret?: string;
|
|
857
|
+
cesServiceToken?: string;
|
|
806
858
|
gatewayPort: number;
|
|
807
859
|
imageTags: Record<ServiceName, string>;
|
|
808
860
|
instanceName: string;
|
|
@@ -824,6 +876,9 @@ function startFileWatcher(opts: {
|
|
|
824
876
|
|
|
825
877
|
const configs = serviceImageConfigs(repoRoot, imageTags);
|
|
826
878
|
const runArgs = serviceDockerRunArgs({
|
|
879
|
+
signingKey: opts.signingKey,
|
|
880
|
+
bootstrapSecret: opts.bootstrapSecret,
|
|
881
|
+
cesServiceToken: opts.cesServiceToken,
|
|
827
882
|
gatewayPort,
|
|
828
883
|
imageTags,
|
|
829
884
|
instanceName,
|
|
@@ -842,6 +897,15 @@ function startFileWatcher(opts: {
|
|
|
842
897
|
const services = pendingServices;
|
|
843
898
|
pendingServices = new Set();
|
|
844
899
|
|
|
900
|
+
// Gateway and CES share the assistant's network namespace. If the
|
|
901
|
+
// assistant container is removed and recreated, the shared namespace
|
|
902
|
+
// is destroyed and the other two lose connectivity. Cascade the
|
|
903
|
+
// restart to all three services in that case.
|
|
904
|
+
if (services.has("assistant")) {
|
|
905
|
+
services.add("gateway");
|
|
906
|
+
services.add("credential-executor");
|
|
907
|
+
}
|
|
908
|
+
|
|
845
909
|
const serviceNames = [...services].join(", ");
|
|
846
910
|
console.log(`\nš Changes detected ā rebuilding: ${serviceNames}`);
|
|
847
911
|
|
|
@@ -854,7 +918,10 @@ function startFileWatcher(opts: {
|
|
|
854
918
|
}),
|
|
855
919
|
);
|
|
856
920
|
|
|
857
|
-
|
|
921
|
+
// Restart in dependency order (assistant first) so the network
|
|
922
|
+
// namespace owner is up before dependents try to attach.
|
|
923
|
+
for (const service of SERVICE_START_ORDER) {
|
|
924
|
+
if (!services.has(service)) continue;
|
|
858
925
|
const container = containerForService[service];
|
|
859
926
|
console.log(`š Restarting ${container}...`);
|
|
860
927
|
await removeContainer(container);
|
|
@@ -949,8 +1016,23 @@ export async function hatchDocker(
|
|
|
949
1016
|
let repoRoot: string | undefined;
|
|
950
1017
|
|
|
951
1018
|
if (watch) {
|
|
952
|
-
emitProgress(2, 6, "Building images...");
|
|
953
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...");
|
|
954
1036
|
const localTag = `local-${instanceName}`;
|
|
955
1037
|
imageTags.assistant = `vellum-assistant:${localTag}`;
|
|
956
1038
|
imageTags.gateway = `vellum-gateway:${localTag}`;
|
|
@@ -969,20 +1051,41 @@ export async function hatchDocker(
|
|
|
969
1051
|
|
|
970
1052
|
await buildAllImages(repoRoot, imageTags, log);
|
|
971
1053
|
log("ā
Docker images built");
|
|
972
|
-
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (!watch || !repoRoot) {
|
|
973
1057
|
emitProgress(2, 6, "Pulling images...");
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
const
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
+
}
|
|
982
1085
|
|
|
983
1086
|
log(`š„ Hatching Docker assistant: ${instanceName}`);
|
|
984
1087
|
log(` Species: ${species}`);
|
|
985
|
-
log(` Images (${
|
|
1088
|
+
log(` Images (${imageSource}):`);
|
|
986
1089
|
log(` assistant: ${imageTags.assistant}`);
|
|
987
1090
|
log(` gateway: ${imageTags.gateway}`);
|
|
988
1091
|
log(` credential-executor: ${imageTags["credential-executor"]}`);
|
|
@@ -1092,6 +1195,9 @@ export async function hatchDocker(
|
|
|
1092
1195
|
saveAssistantEntry({ ...dockerEntry, watcherPid: process.pid });
|
|
1093
1196
|
|
|
1094
1197
|
const stopWatcher = startFileWatcher({
|
|
1198
|
+
signingKey,
|
|
1199
|
+
bootstrapSecret,
|
|
1200
|
+
cesServiceToken,
|
|
1095
1201
|
gatewayPort,
|
|
1096
1202
|
imageTags,
|
|
1097
1203
|
instanceName,
|
package/src/lib/gcp.ts
CHANGED
|
@@ -4,11 +4,8 @@ import { join } from "path";
|
|
|
4
4
|
|
|
5
5
|
import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
|
|
6
6
|
import type { AssistantEntry } from "./assistant-config";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
GATEWAY_PORT,
|
|
10
|
-
PROVIDER_ENV_VAR_NAMES,
|
|
11
|
-
} from "./constants";
|
|
7
|
+
import { FIREWALL_TAG, GATEWAY_PORT } from "./constants";
|
|
8
|
+
import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
12
9
|
import type { Species } from "./constants";
|
|
13
10
|
import { leaseGuardianToken } from "./guardian-token";
|
|
14
11
|
import { getPlatformUrl } from "./platform-client";
|