@vellumai/cli 0.4.55 → 0.4.56
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 +3 -70
- package/package.json +2 -3
- package/src/__tests__/random-name.test.ts +24 -5
- package/src/adapters/install.sh +1 -1
- package/src/adapters/openclaw.ts +6 -3
- package/src/commands/client.ts +2 -3
- package/src/commands/hatch.ts +78 -155
- package/src/commands/pair.ts +2 -2
- package/src/commands/retire.ts +31 -7
- package/src/commands/wake.ts +25 -6
- package/src/components/DefaultMainScreen.tsx +1 -1
- package/src/lib/assistant-config.ts +9 -2
- package/src/lib/aws.ts +11 -37
- package/src/lib/constants.ts +7 -0
- package/src/lib/docker.ts +634 -279
- package/src/lib/gcp.ts +15 -14
- package/src/lib/guardian-token.ts +174 -0
- package/src/lib/health-check.ts +6 -30
- package/src/lib/local.ts +150 -27
- package/src/lib/platform-client.ts +24 -0
- package/src/lib/process.ts +1 -1
- package/src/lib/random-name.ts +17 -1
- package/src/lib/jwt.ts +0 -62
- package/src/lib/policy.ts +0 -7
package/src/lib/docker.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from "child_process";
|
|
2
|
-
import { existsSync } from "fs";
|
|
3
|
-
import { createRequire } from "module";
|
|
2
|
+
import { existsSync, watch as fsWatch } from "fs";
|
|
4
3
|
import { dirname, join } from "path";
|
|
5
4
|
|
|
5
|
+
// Direct import — bun embeds this at compile time so it works in compiled binaries.
|
|
6
|
+
import cliPkg from "../../package.json";
|
|
7
|
+
|
|
6
8
|
import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
|
|
7
9
|
import type { AssistantEntry } from "./assistant-config";
|
|
8
10
|
import { DEFAULT_GATEWAY_PORT } from "./constants";
|
|
9
11
|
import type { Species } from "./constants";
|
|
10
|
-
import {
|
|
12
|
+
import { leaseGuardianToken } from "./guardian-token";
|
|
13
|
+
import { generateInstanceName } from "./random-name";
|
|
11
14
|
import { exec, execOutput } from "./step-runner";
|
|
12
15
|
import {
|
|
13
16
|
closeLogFile,
|
|
@@ -16,7 +19,18 @@ import {
|
|
|
16
19
|
writeToLogFile,
|
|
17
20
|
} from "./xdg-log";
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
type ServiceName = "assistant" | "credential-executor" | "gateway";
|
|
23
|
+
|
|
24
|
+
const DOCKERHUB_ORG = "vellumai";
|
|
25
|
+
const DOCKERHUB_IMAGES: Record<ServiceName, string> = {
|
|
26
|
+
assistant: `${DOCKERHUB_ORG}/vellum-assistant`,
|
|
27
|
+
"credential-executor": `${DOCKERHUB_ORG}/vellum-credential-executor`,
|
|
28
|
+
gateway: `${DOCKERHUB_ORG}/vellum-gateway`,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Internal ports exposed by each service's Dockerfile. */
|
|
32
|
+
const ASSISTANT_INTERNAL_PORT = 3001;
|
|
33
|
+
const GATEWAY_INTERNAL_PORT = 7830;
|
|
20
34
|
|
|
21
35
|
/**
|
|
22
36
|
* Checks whether the `docker` CLI and daemon are available on the system.
|
|
@@ -114,265 +128,557 @@ async function ensureDockerInstalled(): Promise<void> {
|
|
|
114
128
|
}
|
|
115
129
|
}
|
|
116
130
|
|
|
117
|
-
interface DockerRoot {
|
|
118
|
-
/** Directory to use as the Docker build context */
|
|
119
|
-
root: string;
|
|
120
|
-
/** Relative path from root to the directory containing the Dockerfiles */
|
|
121
|
-
dockerfileDir: string;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
131
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
132
|
+
* Creates a line-buffered output prefixer that prepends a tag to each
|
|
133
|
+
* line from a container's stdout/stderr. Calls `onLine` for each complete
|
|
134
|
+
* line so the caller can detect sentinel output (e.g. hatch completion).
|
|
128
135
|
*/
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
136
|
+
function createLinePrefixer(
|
|
137
|
+
stream: NodeJS.WritableStream,
|
|
138
|
+
prefix: string,
|
|
139
|
+
onLine?: (line: string) => void,
|
|
140
|
+
): { write(data: Buffer): void; flush(): void } {
|
|
141
|
+
let remainder = "";
|
|
142
|
+
return {
|
|
143
|
+
write(data: Buffer) {
|
|
144
|
+
const text = remainder + data.toString();
|
|
145
|
+
const lines = text.split("\n");
|
|
146
|
+
remainder = lines.pop() ?? "";
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
stream.write(` [${prefix}] ${line}\n`);
|
|
149
|
+
onLine?.(line);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
flush() {
|
|
153
|
+
if (remainder) {
|
|
154
|
+
stream.write(` [${prefix}] ${remainder}\n`);
|
|
155
|
+
onLine?.(remainder);
|
|
156
|
+
remainder = "";
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Derive the Docker resource names from the instance name. */
|
|
163
|
+
function dockerResourceNames(instanceName: string) {
|
|
164
|
+
return {
|
|
165
|
+
assistantContainer: `${instanceName}-assistant`,
|
|
166
|
+
cesContainer: `${instanceName}-credential-executor`,
|
|
167
|
+
dataVolume: `vellum-data-${instanceName}`,
|
|
168
|
+
gatewayContainer: `${instanceName}-gateway`,
|
|
169
|
+
network: `vellum-net-${instanceName}`,
|
|
170
|
+
socketVolume: `vellum-ces-bootstrap-${instanceName}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Silently attempt to stop and remove a Docker container. */
|
|
175
|
+
async function removeContainer(containerName: string): Promise<void> {
|
|
176
|
+
try {
|
|
177
|
+
await exec("docker", ["stop", containerName]);
|
|
178
|
+
} catch {
|
|
179
|
+
// container may not exist or already stopped
|
|
134
180
|
}
|
|
181
|
+
try {
|
|
182
|
+
await exec("docker", ["rm", containerName]);
|
|
183
|
+
} catch {
|
|
184
|
+
// container may not exist or already removed
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function retireDocker(name: string): Promise<void> {
|
|
189
|
+
console.log(`\u{1F5D1}\ufe0f Stopping Docker containers for '${name}'...\n`);
|
|
190
|
+
|
|
191
|
+
const res = dockerResourceNames(name);
|
|
192
|
+
|
|
193
|
+
await removeContainer(res.cesContainer);
|
|
194
|
+
await removeContainer(res.gatewayContainer);
|
|
195
|
+
await removeContainer(res.assistantContainer);
|
|
196
|
+
|
|
197
|
+
// Also clean up a legacy single-container instance if it exists
|
|
198
|
+
await removeContainer(name);
|
|
135
199
|
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
200
|
+
// Remove shared network and volumes
|
|
201
|
+
try {
|
|
202
|
+
await exec("docker", ["network", "rm", res.network]);
|
|
203
|
+
} catch {
|
|
204
|
+
// network may not exist
|
|
140
205
|
}
|
|
206
|
+
for (const vol of [res.dataVolume, res.socketVolume]) {
|
|
207
|
+
try {
|
|
208
|
+
await exec("docker", ["volume", "rm", vol]);
|
|
209
|
+
} catch {
|
|
210
|
+
// volume may not exist
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log(`\u2705 Docker instance retired.`);
|
|
215
|
+
}
|
|
141
216
|
|
|
142
|
-
|
|
143
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Walk up from `startDir` looking for a directory that contains
|
|
219
|
+
* `assistant/Dockerfile`. Returns the path if found, otherwise `undefined`.
|
|
220
|
+
*/
|
|
221
|
+
function walkUpForRepoRoot(startDir: string): string | undefined {
|
|
222
|
+
let dir = startDir;
|
|
144
223
|
while (true) {
|
|
145
|
-
if (existsSync(join(dir, "
|
|
146
|
-
return
|
|
224
|
+
if (existsSync(join(dir, "assistant", "Dockerfile"))) {
|
|
225
|
+
return dir;
|
|
147
226
|
}
|
|
148
227
|
const parent = dirname(dir);
|
|
149
228
|
if (parent === dir) break;
|
|
150
229
|
dir = parent;
|
|
151
230
|
}
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
152
233
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const parent = dirname(execDir);
|
|
163
|
-
if (parent === execDir) break;
|
|
164
|
-
execDir = parent;
|
|
165
|
-
}
|
|
234
|
+
/**
|
|
235
|
+
* Locate the repository root by walking up from `cli/src/lib/` until we
|
|
236
|
+
* find a directory containing the expected Dockerfiles.
|
|
237
|
+
*/
|
|
238
|
+
function findRepoRoot(): string {
|
|
239
|
+
// cli/src/lib/ -> repo root (works when running from source via bun)
|
|
240
|
+
const sourceTreeRoot = join(import.meta.dir, "..", "..", "..");
|
|
241
|
+
if (existsSync(join(sourceTreeRoot, "assistant", "Dockerfile"))) {
|
|
242
|
+
return sourceTreeRoot;
|
|
166
243
|
}
|
|
167
244
|
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
245
|
+
// Walk up from the compiled binary's location. When the CLI is bundled
|
|
246
|
+
// inside the macOS app (e.g. .../dist/Vellum.app/Contents/MacOS/vellum-cli),
|
|
247
|
+
// the binary still lives inside the repo tree, so walking up will
|
|
248
|
+
// eventually reach the repo root.
|
|
249
|
+
const execRoot = walkUpForRepoRoot(dirname(process.execPath));
|
|
250
|
+
if (execRoot) {
|
|
251
|
+
return execRoot;
|
|
172
252
|
}
|
|
173
253
|
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (existsSync(join(vellumDir, "Dockerfile"))) {
|
|
179
|
-
return { root: vellumDir, dockerfileDir: "." };
|
|
180
|
-
}
|
|
181
|
-
} catch {
|
|
182
|
-
// resolution failed
|
|
254
|
+
// Walk up from cwd as a final fallback
|
|
255
|
+
const cwdRoot = walkUpForRepoRoot(process.cwd());
|
|
256
|
+
if (cwdRoot) {
|
|
257
|
+
return cwdRoot;
|
|
183
258
|
}
|
|
184
259
|
|
|
185
260
|
throw new Error(
|
|
186
|
-
"Could not find
|
|
187
|
-
"
|
|
261
|
+
"Could not find repository root containing assistant/Dockerfile. " +
|
|
262
|
+
"Run this command from within the vellum-assistant repository.",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
interface ServiceImageConfig {
|
|
267
|
+
context: string;
|
|
268
|
+
dockerfile: string;
|
|
269
|
+
tag: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function buildImage(config: ServiceImageConfig): Promise<void> {
|
|
273
|
+
await exec(
|
|
274
|
+
"docker",
|
|
275
|
+
["build", "-f", config.dockerfile, "-t", config.tag, "."],
|
|
276
|
+
{ cwd: config.context },
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function serviceImageConfigs(
|
|
281
|
+
repoRoot: string,
|
|
282
|
+
imageTags: Record<ServiceName, string>,
|
|
283
|
+
): Record<ServiceName, ServiceImageConfig> {
|
|
284
|
+
return {
|
|
285
|
+
assistant: {
|
|
286
|
+
context: repoRoot,
|
|
287
|
+
dockerfile: "assistant/Dockerfile",
|
|
288
|
+
tag: imageTags.assistant,
|
|
289
|
+
},
|
|
290
|
+
"credential-executor": {
|
|
291
|
+
context: repoRoot,
|
|
292
|
+
dockerfile: "credential-executor/Dockerfile",
|
|
293
|
+
tag: imageTags["credential-executor"],
|
|
294
|
+
},
|
|
295
|
+
gateway: {
|
|
296
|
+
context: join(repoRoot, "gateway"),
|
|
297
|
+
dockerfile: "Dockerfile",
|
|
298
|
+
tag: imageTags.gateway,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function buildAllImages(
|
|
304
|
+
repoRoot: string,
|
|
305
|
+
imageTags: Record<ServiceName, string>,
|
|
306
|
+
): Promise<void> {
|
|
307
|
+
const configs = serviceImageConfigs(repoRoot, imageTags);
|
|
308
|
+
console.log("🔨 Building all images in parallel...");
|
|
309
|
+
await Promise.all(
|
|
310
|
+
Object.entries(configs).map(async ([name, config]) => {
|
|
311
|
+
await buildImage(config);
|
|
312
|
+
console.log(`✅ ${name} built`);
|
|
313
|
+
}),
|
|
188
314
|
);
|
|
189
315
|
}
|
|
190
316
|
|
|
191
317
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
318
|
+
* Returns a function that builds the `docker run` arguments for a given
|
|
319
|
+
* service. Each container joins a shared Docker bridge network so they
|
|
320
|
+
* can be restarted independently.
|
|
195
321
|
*/
|
|
196
|
-
function
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
322
|
+
function serviceDockerRunArgs(opts: {
|
|
323
|
+
gatewayPort: number;
|
|
324
|
+
imageTags: Record<ServiceName, string>;
|
|
325
|
+
instanceName: string;
|
|
326
|
+
res: ReturnType<typeof dockerResourceNames>;
|
|
327
|
+
}): Record<ServiceName, () => string[]> {
|
|
328
|
+
const { gatewayPort, imageTags, instanceName, res } = opts;
|
|
201
329
|
return {
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
330
|
+
assistant: () => {
|
|
331
|
+
const args: string[] = [
|
|
332
|
+
"run",
|
|
333
|
+
"--init",
|
|
334
|
+
"-d",
|
|
335
|
+
"--name",
|
|
336
|
+
res.assistantContainer,
|
|
337
|
+
`--network=${res.network}`,
|
|
338
|
+
"-v",
|
|
339
|
+
`${res.dataVolume}:/data`,
|
|
340
|
+
"-v",
|
|
341
|
+
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
342
|
+
"-e",
|
|
343
|
+
`VELLUM_ASSISTANT_NAME=${instanceName}`,
|
|
344
|
+
"-e",
|
|
345
|
+
"RUNTIME_HTTP_HOST=0.0.0.0",
|
|
346
|
+
];
|
|
347
|
+
for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
|
|
348
|
+
if (process.env[envVar]) {
|
|
349
|
+
args.push("-e", `${envVar}=${process.env[envVar]}`);
|
|
350
|
+
}
|
|
216
351
|
}
|
|
352
|
+
args.push(imageTags.assistant);
|
|
353
|
+
return args;
|
|
217
354
|
},
|
|
355
|
+
gateway: () => [
|
|
356
|
+
"run",
|
|
357
|
+
"--init",
|
|
358
|
+
"-d",
|
|
359
|
+
"--name",
|
|
360
|
+
res.gatewayContainer,
|
|
361
|
+
`--network=${res.network}`,
|
|
362
|
+
"-p",
|
|
363
|
+
`${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
|
|
364
|
+
"-v",
|
|
365
|
+
`${res.dataVolume}:/data`,
|
|
366
|
+
"-e",
|
|
367
|
+
"BASE_DATA_DIR=/data",
|
|
368
|
+
"-e",
|
|
369
|
+
`GATEWAY_PORT=${GATEWAY_INTERNAL_PORT}`,
|
|
370
|
+
"-e",
|
|
371
|
+
`ASSISTANT_HOST=${res.assistantContainer}`,
|
|
372
|
+
"-e",
|
|
373
|
+
`RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
|
|
374
|
+
imageTags.gateway,
|
|
375
|
+
],
|
|
376
|
+
"credential-executor": () => [
|
|
377
|
+
"run",
|
|
378
|
+
"--init",
|
|
379
|
+
"-d",
|
|
380
|
+
"--name",
|
|
381
|
+
res.cesContainer,
|
|
382
|
+
`--network=${res.network}`,
|
|
383
|
+
"-v",
|
|
384
|
+
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
385
|
+
"-v",
|
|
386
|
+
`${res.dataVolume}:/data:ro`,
|
|
387
|
+
"-e",
|
|
388
|
+
"CES_MODE=managed",
|
|
389
|
+
"-e",
|
|
390
|
+
"CES_BOOTSTRAP_SOCKET_DIR=/run/ces-bootstrap",
|
|
391
|
+
"-e",
|
|
392
|
+
"CES_ASSISTANT_DATA_MOUNT=/data",
|
|
393
|
+
imageTags["credential-executor"],
|
|
394
|
+
],
|
|
218
395
|
};
|
|
219
396
|
}
|
|
220
397
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return token;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
return null;
|
|
243
|
-
} catch {
|
|
244
|
-
return null;
|
|
398
|
+
/** The order in which services must be started. */
|
|
399
|
+
const SERVICE_START_ORDER: ServiceName[] = [
|
|
400
|
+
"assistant",
|
|
401
|
+
"gateway",
|
|
402
|
+
"credential-executor",
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
/** Start all three containers in dependency order. */
|
|
406
|
+
async function startContainers(opts: {
|
|
407
|
+
gatewayPort: number;
|
|
408
|
+
imageTags: Record<ServiceName, string>;
|
|
409
|
+
instanceName: string;
|
|
410
|
+
res: ReturnType<typeof dockerResourceNames>;
|
|
411
|
+
}): Promise<void> {
|
|
412
|
+
const runArgs = serviceDockerRunArgs(opts);
|
|
413
|
+
for (const service of SERVICE_START_ORDER) {
|
|
414
|
+
console.log(`🚀 Starting ${service} container...`);
|
|
415
|
+
await exec("docker", runArgs[service]());
|
|
245
416
|
}
|
|
246
417
|
}
|
|
247
418
|
|
|
248
|
-
|
|
249
|
-
|
|
419
|
+
/** Stop and remove all three containers (ignoring errors). */
|
|
420
|
+
async function stopContainers(
|
|
421
|
+
res: ReturnType<typeof dockerResourceNames>,
|
|
422
|
+
): Promise<void> {
|
|
423
|
+
await removeContainer(res.cesContainer);
|
|
424
|
+
await removeContainer(res.gatewayContainer);
|
|
425
|
+
await removeContainer(res.assistantContainer);
|
|
426
|
+
}
|
|
250
427
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
428
|
+
/**
|
|
429
|
+
* Determine which services are affected by a changed file path relative
|
|
430
|
+
* to the repository root.
|
|
431
|
+
*/
|
|
432
|
+
function affectedServices(
|
|
433
|
+
filePath: string,
|
|
434
|
+
repoRoot: string,
|
|
435
|
+
): Set<ServiceName> {
|
|
436
|
+
const rel = filePath.startsWith(repoRoot)
|
|
437
|
+
? filePath.slice(repoRoot.length + 1)
|
|
438
|
+
: filePath;
|
|
439
|
+
|
|
440
|
+
const affected = new Set<ServiceName>();
|
|
441
|
+
|
|
442
|
+
if (rel.startsWith("assistant/")) {
|
|
443
|
+
affected.add("assistant");
|
|
444
|
+
}
|
|
445
|
+
if (rel.startsWith("credential-executor/")) {
|
|
446
|
+
affected.add("credential-executor");
|
|
447
|
+
}
|
|
448
|
+
if (rel.startsWith("gateway/")) {
|
|
449
|
+
affected.add("gateway");
|
|
450
|
+
}
|
|
451
|
+
// Shared packages affect both assistant and credential-executor
|
|
452
|
+
if (rel.startsWith("packages/")) {
|
|
453
|
+
affected.add("assistant");
|
|
454
|
+
affected.add("credential-executor");
|
|
257
455
|
}
|
|
258
456
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
457
|
+
return affected;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Watch for file changes in the assistant, gateway, credential-executor,
|
|
462
|
+
* and packages directories. When changes are detected, rebuild the affected
|
|
463
|
+
* images and restart their containers.
|
|
464
|
+
*/
|
|
465
|
+
function startFileWatcher(opts: {
|
|
466
|
+
gatewayPort: number;
|
|
467
|
+
imageTags: Record<ServiceName, string>;
|
|
468
|
+
instanceName: string;
|
|
469
|
+
repoRoot: string;
|
|
470
|
+
res: ReturnType<typeof dockerResourceNames>;
|
|
471
|
+
}): () => void {
|
|
472
|
+
const { gatewayPort, imageTags, instanceName, repoRoot, res } = opts;
|
|
473
|
+
|
|
474
|
+
const watchDirs = [
|
|
475
|
+
join(repoRoot, "assistant"),
|
|
476
|
+
join(repoRoot, "credential-executor"),
|
|
477
|
+
join(repoRoot, "gateway"),
|
|
478
|
+
join(repoRoot, "packages"),
|
|
479
|
+
];
|
|
480
|
+
|
|
481
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
482
|
+
let pendingServices = new Set<ServiceName>();
|
|
483
|
+
let rebuilding = false;
|
|
484
|
+
|
|
485
|
+
const configs = serviceImageConfigs(repoRoot, imageTags);
|
|
486
|
+
const runArgs = serviceDockerRunArgs({
|
|
487
|
+
gatewayPort,
|
|
488
|
+
imageTags,
|
|
489
|
+
instanceName,
|
|
490
|
+
res,
|
|
491
|
+
});
|
|
492
|
+
const containerForService: Record<ServiceName, string> = {
|
|
493
|
+
assistant: res.assistantContainer,
|
|
494
|
+
"credential-executor": res.cesContainer,
|
|
495
|
+
gateway: res.gatewayContainer,
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
async function rebuildAndRestart(): Promise<void> {
|
|
499
|
+
if (rebuilding) return;
|
|
500
|
+
rebuilding = true;
|
|
501
|
+
|
|
502
|
+
const services = pendingServices;
|
|
503
|
+
pendingServices = new Set();
|
|
504
|
+
|
|
505
|
+
const serviceNames = [...services].join(", ");
|
|
506
|
+
console.log(`\n🔄 Changes detected — rebuilding: ${serviceNames}`);
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
await Promise.all(
|
|
510
|
+
[...services].map(async (service) => {
|
|
511
|
+
console.log(`🔨 Building ${service}...`);
|
|
512
|
+
await buildImage(configs[service]);
|
|
513
|
+
console.log(`✅ ${service} built`);
|
|
514
|
+
}),
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
for (const service of services) {
|
|
518
|
+
const container = containerForService[service];
|
|
519
|
+
console.log(`🔄 Restarting ${container}...`);
|
|
520
|
+
await removeContainer(container);
|
|
521
|
+
await exec("docker", runArgs[service]());
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
console.log("✅ Rebuild complete — watching for changes...\n");
|
|
525
|
+
} catch (err) {
|
|
526
|
+
console.error(
|
|
527
|
+
`❌ Rebuild failed: ${err instanceof Error ? err.message : err}`,
|
|
528
|
+
);
|
|
529
|
+
console.log(" Watching for changes...\n");
|
|
530
|
+
} finally {
|
|
531
|
+
rebuilding = false;
|
|
532
|
+
if (pendingServices.size > 0) {
|
|
533
|
+
rebuildAndRestart();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
265
536
|
}
|
|
266
537
|
|
|
267
|
-
|
|
538
|
+
const watchers: ReturnType<typeof fsWatch>[] = [];
|
|
539
|
+
|
|
540
|
+
for (const dir of watchDirs) {
|
|
541
|
+
if (!existsSync(dir)) continue;
|
|
542
|
+
const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
|
|
543
|
+
if (!filename) return;
|
|
544
|
+
if (
|
|
545
|
+
filename.includes("node_modules") ||
|
|
546
|
+
filename.includes(".env") ||
|
|
547
|
+
filename.startsWith(".")
|
|
548
|
+
) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const fullPath = join(dir, filename);
|
|
553
|
+
const services = affectedServices(fullPath, repoRoot);
|
|
554
|
+
if (services.size === 0) return;
|
|
555
|
+
|
|
556
|
+
for (const s of services) {
|
|
557
|
+
pendingServices.add(s);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
561
|
+
debounceTimer = setTimeout(() => {
|
|
562
|
+
debounceTimer = null;
|
|
563
|
+
rebuildAndRestart();
|
|
564
|
+
}, 500);
|
|
565
|
+
});
|
|
566
|
+
watchers.push(watcher);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
console.log("👀 Watching for file changes in:");
|
|
570
|
+
console.log(" assistant/, gateway/, credential-executor/, packages/");
|
|
571
|
+
console.log("");
|
|
572
|
+
|
|
573
|
+
return () => {
|
|
574
|
+
for (const watcher of watchers) {
|
|
575
|
+
watcher.close();
|
|
576
|
+
}
|
|
577
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
578
|
+
};
|
|
268
579
|
}
|
|
269
580
|
|
|
270
581
|
export async function hatchDocker(
|
|
271
582
|
species: Species,
|
|
272
583
|
detached: boolean,
|
|
273
584
|
name: string | null,
|
|
274
|
-
watch: boolean,
|
|
585
|
+
watch: boolean = false,
|
|
275
586
|
): Promise<void> {
|
|
276
587
|
resetLogFile("hatch.log");
|
|
277
588
|
|
|
278
589
|
await ensureDockerInstalled();
|
|
279
590
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
try {
|
|
283
|
-
({ root: repoRoot, dockerfileDir } = findDockerRoot(watch));
|
|
284
|
-
} catch (err) {
|
|
285
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
286
|
-
const logFd = openLogFile("hatch.log");
|
|
287
|
-
writeToLogFile(
|
|
288
|
-
logFd,
|
|
289
|
-
`[docker-hatch] ${new Date().toISOString()} ERROR\n${message}\n`,
|
|
290
|
-
);
|
|
291
|
-
closeLogFile(logFd);
|
|
292
|
-
console.error(message);
|
|
293
|
-
throw err;
|
|
294
|
-
}
|
|
591
|
+
const instanceName = generateInstanceName(species, name);
|
|
592
|
+
const gatewayPort = DEFAULT_GATEWAY_PORT;
|
|
295
593
|
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
594
|
+
const imageTags: Record<ServiceName, string> = {
|
|
595
|
+
assistant: "",
|
|
596
|
+
"credential-executor": "",
|
|
597
|
+
gateway: "",
|
|
598
|
+
};
|
|
300
599
|
|
|
301
|
-
|
|
302
|
-
const message = `Error: ${dockerfile} not found at ${dockerfilePath}`;
|
|
303
|
-
const logFd = openLogFile("hatch.log");
|
|
304
|
-
writeToLogFile(
|
|
305
|
-
logFd,
|
|
306
|
-
`[docker-hatch] ${new Date().toISOString()} ERROR\n${message}\n`,
|
|
307
|
-
);
|
|
308
|
-
closeLogFile(logFd);
|
|
309
|
-
console.error(message);
|
|
310
|
-
process.exit(1);
|
|
311
|
-
}
|
|
600
|
+
let repoRoot: string | undefined;
|
|
312
601
|
|
|
313
|
-
console.log(`🥚 Hatching Docker assistant: ${instanceName}`);
|
|
314
|
-
console.log(` Species: ${species}`);
|
|
315
|
-
console.log(` Dockerfile: ${dockerfile}`);
|
|
316
602
|
if (watch) {
|
|
603
|
+
repoRoot = findRepoRoot();
|
|
604
|
+
const localTag = `local-${instanceName}`;
|
|
605
|
+
imageTags.assistant = `vellum-assistant:${localTag}`;
|
|
606
|
+
imageTags.gateway = `vellum-gateway:${localTag}`;
|
|
607
|
+
imageTags["credential-executor"] = `vellum-credential-executor:${localTag}`;
|
|
608
|
+
|
|
609
|
+
console.log(`🥚 Hatching Docker assistant: ${instanceName}`);
|
|
610
|
+
console.log(` Species: ${species}`);
|
|
317
611
|
console.log(` Mode: development (watch)`);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
try {
|
|
325
|
-
await exec("docker", ["build", "-f", dockerfile, "-t", imageTag, "."], {
|
|
326
|
-
cwd: repoRoot,
|
|
327
|
-
});
|
|
328
|
-
} catch (err) {
|
|
329
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
330
|
-
writeToLogFile(
|
|
331
|
-
logFd,
|
|
332
|
-
`[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`,
|
|
612
|
+
console.log(` Repo: ${repoRoot}`);
|
|
613
|
+
console.log(` Images (local build):`);
|
|
614
|
+
console.log(` assistant: ${imageTags.assistant}`);
|
|
615
|
+
console.log(` gateway: ${imageTags.gateway}`);
|
|
616
|
+
console.log(
|
|
617
|
+
` credential-executor: ${imageTags["credential-executor"]}`,
|
|
333
618
|
);
|
|
334
|
-
|
|
335
|
-
throw err;
|
|
336
|
-
}
|
|
337
|
-
closeLogFile(logFd);
|
|
338
|
-
console.log("✅ Docker image built\n");
|
|
619
|
+
console.log("");
|
|
339
620
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
621
|
+
const logFd = openLogFile("hatch.log");
|
|
622
|
+
try {
|
|
623
|
+
await buildAllImages(repoRoot, imageTags);
|
|
624
|
+
} catch (err) {
|
|
625
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
626
|
+
writeToLogFile(
|
|
627
|
+
logFd,
|
|
628
|
+
`[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`,
|
|
629
|
+
);
|
|
630
|
+
closeLogFile(logFd);
|
|
631
|
+
throw err;
|
|
632
|
+
}
|
|
633
|
+
closeLogFile(logFd);
|
|
634
|
+
console.log("✅ Docker images built\n");
|
|
635
|
+
} else {
|
|
636
|
+
const version = cliPkg.version;
|
|
637
|
+
const versionTag = version ? `v${version}` : "latest";
|
|
638
|
+
imageTags.assistant = `${DOCKERHUB_IMAGES.assistant}:${versionTag}`;
|
|
639
|
+
imageTags.gateway = `${DOCKERHUB_IMAGES.gateway}:${versionTag}`;
|
|
640
|
+
imageTags["credential-executor"] =
|
|
641
|
+
`${DOCKERHUB_IMAGES["credential-executor"]}:${versionTag}`;
|
|
642
|
+
|
|
643
|
+
console.log(`🥚 Hatching Docker assistant: ${instanceName}`);
|
|
644
|
+
console.log(` Species: ${species}`);
|
|
645
|
+
console.log(` Images:`);
|
|
646
|
+
console.log(` assistant: ${imageTags.assistant}`);
|
|
647
|
+
console.log(` gateway: ${imageTags.gateway}`);
|
|
648
|
+
console.log(
|
|
649
|
+
` credential-executor: ${imageTags["credential-executor"]}`,
|
|
650
|
+
);
|
|
651
|
+
console.log("");
|
|
349
652
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
653
|
+
const logFd = openLogFile("hatch.log");
|
|
654
|
+
console.log("📦 Pulling Docker images...");
|
|
655
|
+
try {
|
|
656
|
+
await exec("docker", ["pull", imageTags.assistant]);
|
|
657
|
+
await exec("docker", ["pull", imageTags.gateway]);
|
|
658
|
+
await exec("docker", ["pull", imageTags["credential-executor"]]);
|
|
659
|
+
} catch (err) {
|
|
660
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
661
|
+
writeToLogFile(
|
|
662
|
+
logFd,
|
|
663
|
+
`[docker-pull] ${new Date().toISOString()} ERROR\n${message}\n`,
|
|
664
|
+
);
|
|
665
|
+
closeLogFile(logFd);
|
|
666
|
+
throw err;
|
|
354
667
|
}
|
|
668
|
+
closeLogFile(logFd);
|
|
669
|
+
console.log("✅ Docker images pulled\n");
|
|
355
670
|
}
|
|
356
671
|
|
|
357
|
-
|
|
358
|
-
// instead of generating a new random one.
|
|
359
|
-
runArgs.push("-e", `VELLUM_ASSISTANT_NAME=${instanceName}`);
|
|
672
|
+
const res = dockerResourceNames(instanceName);
|
|
360
673
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
"-v",
|
|
369
|
-
`${join(repoRoot, "cli", "src")}:/app/cli/src`,
|
|
370
|
-
);
|
|
371
|
-
}
|
|
674
|
+
// Create shared network and volumes
|
|
675
|
+
console.log("📁 Creating shared network and volumes...");
|
|
676
|
+
await exec("docker", ["network", "create", res.network]);
|
|
677
|
+
await exec("docker", ["volume", "create", res.dataVolume]);
|
|
678
|
+
await exec("docker", ["volume", "create", res.socketVolume]);
|
|
679
|
+
|
|
680
|
+
await startContainers({ gatewayPort, imageTags, instanceName, res });
|
|
372
681
|
|
|
373
|
-
// Docker containers bind to 0.0.0.0 so localhost always works. Skip
|
|
374
|
-
// mDNS/LAN discovery — the .local hostname often fails to resolve on the
|
|
375
|
-
// host machine itself (mDNS is designed for cross-device discovery).
|
|
376
682
|
const runtimeUrl = `http://localhost:${gatewayPort}`;
|
|
377
683
|
const dockerEntry: AssistantEntry = {
|
|
378
684
|
assistantId: instanceName,
|
|
@@ -380,101 +686,150 @@ export async function hatchDocker(
|
|
|
380
686
|
cloud: "docker",
|
|
381
687
|
species,
|
|
382
688
|
hatchedAt: new Date().toISOString(),
|
|
689
|
+
volume: res.dataVolume,
|
|
383
690
|
};
|
|
384
691
|
saveAssistantEntry(dockerEntry);
|
|
385
692
|
setActiveAssistant(instanceName);
|
|
386
693
|
|
|
387
|
-
// The
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
"--keep-alive",
|
|
398
|
-
]
|
|
399
|
-
: [];
|
|
400
|
-
|
|
401
|
-
// Always start the container detached so it keeps running after the CLI exits.
|
|
402
|
-
runArgs.push("-d");
|
|
403
|
-
console.log("🚀 Starting Docker container...");
|
|
404
|
-
await exec("docker", [...runArgs, imageTag, ...containerCmd], {
|
|
405
|
-
cwd: repoRoot,
|
|
694
|
+
// The assistant image runs the daemon directly (not via the CLI hatch
|
|
695
|
+
// command), so we watch for the DaemonServer readiness message instead
|
|
696
|
+
// of the CLI's "Local assistant hatched!" sentinel.
|
|
697
|
+
await tailContainerUntilReady({
|
|
698
|
+
containerName: res.assistantContainer,
|
|
699
|
+
detached: watch ? false : detached,
|
|
700
|
+
dockerEntry,
|
|
701
|
+
instanceName,
|
|
702
|
+
runtimeUrl,
|
|
703
|
+
sentinel: "DaemonServer started",
|
|
406
704
|
});
|
|
407
705
|
|
|
706
|
+
if (watch && repoRoot) {
|
|
707
|
+
const stopWatcher = startFileWatcher({
|
|
708
|
+
gatewayPort,
|
|
709
|
+
imageTags,
|
|
710
|
+
instanceName,
|
|
711
|
+
repoRoot,
|
|
712
|
+
res,
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
await new Promise<void>((resolve) => {
|
|
716
|
+
const cleanup = async () => {
|
|
717
|
+
console.log("\n🛑 Shutting down...");
|
|
718
|
+
stopWatcher();
|
|
719
|
+
await stopContainers(res);
|
|
720
|
+
console.log("✅ Docker instance stopped.");
|
|
721
|
+
resolve();
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
process.on("SIGINT", () => void cleanup());
|
|
725
|
+
process.on("SIGTERM", () => void cleanup());
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* In detached mode, print instance details and return immediately.
|
|
732
|
+
* Otherwise, tail the given container's logs until the sentinel string
|
|
733
|
+
* appears, then attempt to lease a guardian token and report readiness.
|
|
734
|
+
*/
|
|
735
|
+
async function tailContainerUntilReady(opts: {
|
|
736
|
+
containerName: string;
|
|
737
|
+
detached: boolean;
|
|
738
|
+
dockerEntry: AssistantEntry;
|
|
739
|
+
instanceName: string;
|
|
740
|
+
runtimeUrl: string;
|
|
741
|
+
sentinel: string;
|
|
742
|
+
}): Promise<void> {
|
|
743
|
+
const {
|
|
744
|
+
containerName,
|
|
745
|
+
detached,
|
|
746
|
+
dockerEntry,
|
|
747
|
+
instanceName,
|
|
748
|
+
runtimeUrl,
|
|
749
|
+
sentinel,
|
|
750
|
+
} = opts;
|
|
751
|
+
|
|
408
752
|
if (detached) {
|
|
409
753
|
console.log("\n✅ Docker assistant hatched!\n");
|
|
410
754
|
console.log("Instance details:");
|
|
411
755
|
console.log(` Name: ${instanceName}`);
|
|
412
756
|
console.log(` Runtime: ${runtimeUrl}`);
|
|
413
|
-
console.log(` Container: ${
|
|
414
|
-
console.log("");
|
|
415
|
-
console.log(`Stop with: docker stop ${instanceName}`);
|
|
416
|
-
} else {
|
|
417
|
-
console.log(` Container: ${instanceName}`);
|
|
418
|
-
console.log(` Runtime: ${runtimeUrl}`);
|
|
757
|
+
console.log(` Container: ${containerName}`);
|
|
419
758
|
console.log("");
|
|
759
|
+
console.log(`Stop with: vellum retire ${instanceName}`);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
420
762
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (line.includes("Local assistant hatched!")) {
|
|
430
|
-
process.nextTick(async () => {
|
|
431
|
-
const remoteBearerToken =
|
|
432
|
-
await fetchRemoteBearerToken(instanceName);
|
|
433
|
-
if (remoteBearerToken) {
|
|
434
|
-
dockerEntry.bearerToken = remoteBearerToken;
|
|
435
|
-
saveAssistantEntry(dockerEntry);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
console.log("");
|
|
439
|
-
console.log(`\u2705 Docker container is up and running!`);
|
|
440
|
-
console.log(` Name: ${instanceName}`);
|
|
441
|
-
console.log(` Runtime: ${runtimeUrl}`);
|
|
442
|
-
console.log("");
|
|
443
|
-
child.kill();
|
|
444
|
-
resolve();
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
};
|
|
763
|
+
console.log(` Container: ${containerName}`);
|
|
764
|
+
console.log(` Runtime: ${runtimeUrl}`);
|
|
765
|
+
console.log("");
|
|
766
|
+
|
|
767
|
+
await new Promise<void>((resolve, reject) => {
|
|
768
|
+
const child = nodeSpawn("docker", ["logs", "-f", containerName], {
|
|
769
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
770
|
+
});
|
|
448
771
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
772
|
+
const handleLine = (line: string): void => {
|
|
773
|
+
if (line.includes(sentinel)) {
|
|
774
|
+
process.nextTick(async () => {
|
|
775
|
+
try {
|
|
776
|
+
const tokenData = await leaseGuardianToken(
|
|
777
|
+
runtimeUrl,
|
|
778
|
+
instanceName,
|
|
779
|
+
);
|
|
780
|
+
dockerEntry.bearerToken = tokenData.accessToken;
|
|
781
|
+
saveAssistantEntry(dockerEntry);
|
|
782
|
+
} catch (err) {
|
|
783
|
+
console.warn(
|
|
784
|
+
`\u26a0\ufe0f Could not lease guardian token: ${err instanceof Error ? err.message : err}`,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
console.log("");
|
|
789
|
+
console.log(`\u2705 Docker containers are up and running!`);
|
|
790
|
+
console.log(` Name: ${instanceName}`);
|
|
791
|
+
console.log(` Runtime: ${runtimeUrl}`);
|
|
792
|
+
console.log("");
|
|
793
|
+
child.kill();
|
|
467
794
|
resolve();
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const stdoutPrefixer = createLinePrefixer(
|
|
800
|
+
process.stdout,
|
|
801
|
+
"docker",
|
|
802
|
+
handleLine,
|
|
803
|
+
);
|
|
804
|
+
const stderrPrefixer = createLinePrefixer(
|
|
805
|
+
process.stderr,
|
|
806
|
+
"docker",
|
|
807
|
+
handleLine,
|
|
808
|
+
);
|
|
473
809
|
|
|
474
|
-
|
|
475
|
-
|
|
810
|
+
child.stdout?.on("data", (data: Buffer) => stdoutPrefixer.write(data));
|
|
811
|
+
child.stderr?.on("data", (data: Buffer) => stderrPrefixer.write(data));
|
|
812
|
+
child.stdout?.on("end", () => stdoutPrefixer.flush());
|
|
813
|
+
child.stderr?.on("end", () => stderrPrefixer.flush());
|
|
814
|
+
|
|
815
|
+
child.on("close", (code) => {
|
|
816
|
+
if (
|
|
817
|
+
code === 0 ||
|
|
818
|
+
code === null ||
|
|
819
|
+
code === 130 ||
|
|
820
|
+
code === 137 ||
|
|
821
|
+
code === 143
|
|
822
|
+
) {
|
|
476
823
|
resolve();
|
|
477
|
-
}
|
|
824
|
+
} else {
|
|
825
|
+
reject(new Error(`Docker container exited with code ${code}`));
|
|
826
|
+
}
|
|
478
827
|
});
|
|
479
|
-
|
|
828
|
+
child.on("error", reject);
|
|
829
|
+
|
|
830
|
+
process.on("SIGINT", () => {
|
|
831
|
+
child.kill();
|
|
832
|
+
resolve();
|
|
833
|
+
});
|
|
834
|
+
});
|
|
480
835
|
}
|