@vellumai/cli 0.4.31 → 0.4.33
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/commands/client.ts +1 -17
- package/src/commands/contacts.ts +1 -19
- package/src/commands/hatch.ts +2 -2
- package/src/commands/ps.ts +1 -1
- package/src/commands/retire.ts +3 -2
- package/src/commands/sleep.ts +8 -2
- package/src/lib/gcp.ts +0 -73
- package/src/lib/health-check.ts +10 -2
- package/src/lib/local.ts +93 -74
- package/src/lib/process.ts +5 -3
- package/src/lib/step-runner.ts +0 -41
package/package.json
CHANGED
package/src/commands/client.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { readFileSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
|
|
4
1
|
import {
|
|
5
2
|
findAssistantByName,
|
|
6
3
|
loadLatestAssistant,
|
|
@@ -60,21 +57,8 @@ function parseArgs(): ParsedArgs {
|
|
|
60
57
|
process.env.RUNTIME_URL || entry?.runtimeUrl || FALLBACK_RUNTIME_URL;
|
|
61
58
|
let assistantId =
|
|
62
59
|
process.env.ASSISTANT_ID || entry?.assistantId || FALLBACK_ASSISTANT_ID;
|
|
63
|
-
|
|
60
|
+
const bearerToken =
|
|
64
61
|
process.env.RUNTIME_PROXY_BEARER_TOKEN || entry?.bearerToken || undefined;
|
|
65
|
-
|
|
66
|
-
// For local assistants, read the daemon's http-token file as a fallback
|
|
67
|
-
// when the lockfile doesn't include a bearer token.
|
|
68
|
-
if (!bearerToken && entry?.cloud === "local") {
|
|
69
|
-
const tokenDir =
|
|
70
|
-
entry.baseDataDir ?? join(process.env.HOME ?? "", ".vellum");
|
|
71
|
-
try {
|
|
72
|
-
const token = readFileSync(join(tokenDir, "http-token"), "utf-8").trim();
|
|
73
|
-
if (token) bearerToken = token;
|
|
74
|
-
} catch {
|
|
75
|
-
// Token file may not exist
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
62
|
const species: Species = (entry?.species as Species) ?? "vellum";
|
|
79
63
|
|
|
80
64
|
for (let i = 0; i < flagArgs.length; i++) {
|
package/src/commands/contacts.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
1
|
import { loadLatestAssistant } from "../lib/assistant-config";
|
|
6
2
|
import { GATEWAY_PORT } from "../lib/constants.js";
|
|
7
3
|
|
|
@@ -17,21 +13,7 @@ function getGatewayUrl(): string {
|
|
|
17
13
|
|
|
18
14
|
function getBearerToken(): string | undefined {
|
|
19
15
|
const entry = loadLatestAssistant();
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
const tokenPath = join(
|
|
23
|
-
process.env.BASE_DATA_DIR?.trim() || homedir(),
|
|
24
|
-
".vellum",
|
|
25
|
-
"http-token",
|
|
26
|
-
);
|
|
27
|
-
if (existsSync(tokenPath)) {
|
|
28
|
-
const token = readFileSync(tokenPath, "utf-8").trim();
|
|
29
|
-
if (token) return token;
|
|
30
|
-
}
|
|
31
|
-
} catch {
|
|
32
|
-
// ignore
|
|
33
|
-
}
|
|
34
|
-
return undefined;
|
|
16
|
+
return entry?.bearerToken;
|
|
35
17
|
}
|
|
36
18
|
|
|
37
19
|
function buildHeaders(): Record<string, string> {
|
package/src/commands/hatch.ts
CHANGED
|
@@ -756,8 +756,8 @@ async function hatchLocal(
|
|
|
756
756
|
throw error;
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
-
// Read the bearer token written by the daemon so the
|
|
760
|
-
// with the gateway
|
|
759
|
+
// Read the bearer token (JWT) written by the daemon so the CLI can
|
|
760
|
+
// authenticate with the gateway.
|
|
761
761
|
let bearerToken: string | undefined;
|
|
762
762
|
try {
|
|
763
763
|
const token = readFileSync(join(baseDataDir, "http-token"), "utf-8").trim();
|
package/src/commands/ps.ts
CHANGED
|
@@ -403,7 +403,7 @@ async function listAllAssistants(): Promise<void> {
|
|
|
403
403
|
|
|
404
404
|
await Promise.all(
|
|
405
405
|
assistants.map(async (a, rowIndex) => {
|
|
406
|
-
const health = await checkHealth(a.runtimeUrl);
|
|
406
|
+
const health = await checkHealth(a.runtimeUrl, a.bearerToken);
|
|
407
407
|
|
|
408
408
|
const infoParts = [a.runtimeUrl];
|
|
409
409
|
if (a.cloud) infoParts.push(`cloud: ${a.cloud}`);
|
package/src/commands/retire.ts
CHANGED
|
@@ -56,9 +56,10 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
|
|
|
56
56
|
socketFile,
|
|
57
57
|
]);
|
|
58
58
|
|
|
59
|
-
// Stop gateway via PID file
|
|
59
|
+
// Stop gateway via PID file — use a longer timeout because the gateway has a
|
|
60
|
+
// configurable drain window (GATEWAY_SHUTDOWN_DRAIN_MS, default 5s) before it exits.
|
|
60
61
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
61
|
-
await stopProcessByPidFile(gatewayPidFile, "gateway");
|
|
62
|
+
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
|
|
62
63
|
|
|
63
64
|
// If the PID file didn't track a running daemon, scan for orphaned
|
|
64
65
|
// daemon processes that may have been started without writing a PID.
|
package/src/commands/sleep.ts
CHANGED
|
@@ -27,8 +27,14 @@ export async function sleep(): Promise<void> {
|
|
|
27
27
|
console.log("Assistant stopped.");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
// Stop gateway
|
|
31
|
-
|
|
30
|
+
// Stop gateway — use a longer timeout because the gateway has a configurable
|
|
31
|
+
// drain window (GATEWAY_SHUTDOWN_DRAIN_MS, default 5s) before it exits.
|
|
32
|
+
const gatewayStopped = await stopProcessByPidFile(
|
|
33
|
+
gatewayPidFile,
|
|
34
|
+
"gateway",
|
|
35
|
+
undefined,
|
|
36
|
+
7000,
|
|
37
|
+
);
|
|
32
38
|
if (!gatewayStopped) {
|
|
33
39
|
console.log("Gateway is not running.");
|
|
34
40
|
} else {
|
package/src/lib/gcp.ts
CHANGED
|
@@ -198,58 +198,6 @@ export async function syncFirewallRules(
|
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
export async function fetchFirewallRules(
|
|
202
|
-
project: string,
|
|
203
|
-
tag: string,
|
|
204
|
-
): Promise<string> {
|
|
205
|
-
const output = await execOutput("gcloud", [
|
|
206
|
-
"compute",
|
|
207
|
-
"firewall-rules",
|
|
208
|
-
"list",
|
|
209
|
-
`--project=${project}`,
|
|
210
|
-
"--format=json",
|
|
211
|
-
]);
|
|
212
|
-
const rules = JSON.parse(output) as Array<{ targetTags?: string[] }>;
|
|
213
|
-
const filtered = rules.filter((r) => r.targetTags?.includes(tag));
|
|
214
|
-
return JSON.stringify(filtered, null, 2);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export interface GcpInstance {
|
|
218
|
-
name: string;
|
|
219
|
-
zone: string;
|
|
220
|
-
externalIp: string | null;
|
|
221
|
-
species: string | null;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export async function listAssistantInstances(
|
|
225
|
-
project: string,
|
|
226
|
-
): Promise<GcpInstance[]> {
|
|
227
|
-
const output = await execOutput("gcloud", [
|
|
228
|
-
"compute",
|
|
229
|
-
"instances",
|
|
230
|
-
"list",
|
|
231
|
-
`--project=${project}`,
|
|
232
|
-
"--filter=labels.vellum-assistant=true",
|
|
233
|
-
"--format=json(name,zone,networkInterfaces[0].accessConfigs[0].natIP,labels)",
|
|
234
|
-
]);
|
|
235
|
-
const parsed = JSON.parse(output) as Array<{
|
|
236
|
-
name: string;
|
|
237
|
-
zone: string;
|
|
238
|
-
networkInterfaces?: Array<{ accessConfigs?: Array<{ natIP?: string }> }>;
|
|
239
|
-
labels?: Record<string, string>;
|
|
240
|
-
}>;
|
|
241
|
-
return parsed.map((inst) => {
|
|
242
|
-
const zoneParts = (inst.zone ?? "").split("/");
|
|
243
|
-
return {
|
|
244
|
-
name: inst.name,
|
|
245
|
-
zone: zoneParts[zoneParts.length - 1] || "",
|
|
246
|
-
externalIp:
|
|
247
|
-
inst.networkInterfaces?.[0]?.accessConfigs?.[0]?.natIP ?? null,
|
|
248
|
-
species: inst.labels?.species ?? null,
|
|
249
|
-
};
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
201
|
export async function instanceExists(
|
|
254
202
|
instanceName: string,
|
|
255
203
|
project: string,
|
|
@@ -281,27 +229,6 @@ export async function instanceExists(
|
|
|
281
229
|
}
|
|
282
230
|
}
|
|
283
231
|
|
|
284
|
-
export async function sshCommand(
|
|
285
|
-
instanceName: string,
|
|
286
|
-
project: string,
|
|
287
|
-
zone: string,
|
|
288
|
-
command: string,
|
|
289
|
-
): Promise<string> {
|
|
290
|
-
return execOutput("gcloud", [
|
|
291
|
-
"compute",
|
|
292
|
-
"ssh",
|
|
293
|
-
instanceName,
|
|
294
|
-
`--project=${project}`,
|
|
295
|
-
`--zone=${zone}`,
|
|
296
|
-
"--quiet",
|
|
297
|
-
"--ssh-flag=-o StrictHostKeyChecking=no",
|
|
298
|
-
"--ssh-flag=-o UserKnownHostsFile=/dev/null",
|
|
299
|
-
"--ssh-flag=-o ConnectTimeout=5",
|
|
300
|
-
"--ssh-flag=-o LogLevel=ERROR",
|
|
301
|
-
`--command=${command}`,
|
|
302
|
-
]);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
232
|
export async function fetchAndDisplayStartupLogs(
|
|
306
233
|
instanceName: string,
|
|
307
234
|
project: string,
|
package/src/lib/health-check.ts
CHANGED
|
@@ -12,18 +12,26 @@ export interface HealthCheckResult {
|
|
|
12
12
|
|
|
13
13
|
export async function checkHealth(
|
|
14
14
|
runtimeUrl: string,
|
|
15
|
+
bearerToken?: string,
|
|
15
16
|
): Promise<HealthCheckResult> {
|
|
16
17
|
try {
|
|
17
|
-
const url = `${runtimeUrl}/
|
|
18
|
+
const url = `${runtimeUrl}/v1/health`;
|
|
18
19
|
const controller = new AbortController();
|
|
19
20
|
const timeoutId = setTimeout(
|
|
20
21
|
() => controller.abort(),
|
|
21
22
|
HEALTH_CHECK_TIMEOUT_MS,
|
|
22
23
|
);
|
|
23
24
|
|
|
25
|
+
const headers: Record<string, string> = {
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
};
|
|
28
|
+
if (bearerToken) {
|
|
29
|
+
headers["Authorization"] = `Bearer ${bearerToken}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
const response = await fetch(url, {
|
|
25
33
|
signal: controller.signal,
|
|
26
|
-
headers
|
|
34
|
+
headers,
|
|
27
35
|
});
|
|
28
36
|
|
|
29
37
|
clearTimeout(timeoutId);
|
package/src/lib/local.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSync, spawn } from "child_process";
|
|
1
|
+
import { execFileSync, execSync, spawn } from "child_process";
|
|
2
2
|
import {
|
|
3
3
|
closeSync,
|
|
4
4
|
existsSync,
|
|
@@ -149,6 +149,63 @@ async function waitForSocketFile(
|
|
|
149
149
|
return existsSync(socketPath);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
function ensureBunInstalled(): void {
|
|
153
|
+
const bunBinDir = join(homedir(), ".bun", "bin");
|
|
154
|
+
const pathWithBun = [
|
|
155
|
+
bunBinDir,
|
|
156
|
+
process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
157
|
+
].join(":");
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
execFileSync("bun", ["--version"], {
|
|
161
|
+
stdio: "pipe",
|
|
162
|
+
env: { ...process.env, PATH: pathWithBun },
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
} catch {
|
|
166
|
+
// bun not found, try to install
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.log(" Installing bun...");
|
|
170
|
+
try {
|
|
171
|
+
const installEnv: Record<string, string> = {
|
|
172
|
+
HOME: process.env.HOME || homedir(),
|
|
173
|
+
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
174
|
+
TMPDIR: process.env.TMPDIR || "/tmp",
|
|
175
|
+
USER: process.env.USER || "",
|
|
176
|
+
LANG: process.env.LANG || "",
|
|
177
|
+
};
|
|
178
|
+
// Preserve proxy/TLS env vars so curl works in proxied/corporate environments
|
|
179
|
+
for (const key of [
|
|
180
|
+
"HTTP_PROXY",
|
|
181
|
+
"http_proxy",
|
|
182
|
+
"HTTPS_PROXY",
|
|
183
|
+
"https_proxy",
|
|
184
|
+
"ALL_PROXY",
|
|
185
|
+
"all_proxy",
|
|
186
|
+
"NO_PROXY",
|
|
187
|
+
"no_proxy",
|
|
188
|
+
"SSL_CERT_FILE",
|
|
189
|
+
"SSL_CERT_DIR",
|
|
190
|
+
"CURL_CA_BUNDLE",
|
|
191
|
+
]) {
|
|
192
|
+
if (process.env[key]) {
|
|
193
|
+
installEnv[key] = process.env[key]!;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
execSync("curl -fsSL https://bun.sh/install | bash", {
|
|
197
|
+
stdio: "pipe",
|
|
198
|
+
timeout: 60_000,
|
|
199
|
+
env: installEnv,
|
|
200
|
+
});
|
|
201
|
+
console.log(" Bun installed successfully");
|
|
202
|
+
} catch {
|
|
203
|
+
console.log(
|
|
204
|
+
" ⚠️ Failed to install bun — some features may be unavailable",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
152
209
|
function resolveDaemonMainPath(assistantIndex: string): string {
|
|
153
210
|
return join(dirname(assistantIndex), "daemon", "main.ts");
|
|
154
211
|
}
|
|
@@ -605,6 +662,9 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
|
|
|
605
662
|
} else {
|
|
606
663
|
console.log(" Assistant socket is responsive — skipping restart\n");
|
|
607
664
|
}
|
|
665
|
+
// Ensure bun is available for runtime features (browser, skills install)
|
|
666
|
+
// even when reusing an existing daemon.
|
|
667
|
+
ensureBunInstalled();
|
|
608
668
|
return;
|
|
609
669
|
}
|
|
610
670
|
|
|
@@ -615,6 +675,9 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
|
|
|
615
675
|
|
|
616
676
|
console.log("🔨 Starting assistant...");
|
|
617
677
|
|
|
678
|
+
// Ensure bun is available for runtime features (browser, skills install)
|
|
679
|
+
ensureBunInstalled();
|
|
680
|
+
|
|
618
681
|
// Ensure ~/.vellum/ exists for PID/socket files
|
|
619
682
|
mkdirSync(vellumDir, { recursive: true });
|
|
620
683
|
|
|
@@ -622,10 +685,12 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
|
|
|
622
685
|
// macOS app the CLI inherits a huge environment (XPC_SERVICE_NAME,
|
|
623
686
|
// __CFBundleIdentifier, CLAUDE_CODE_ENTRYPOINT, etc.) that can cause
|
|
624
687
|
// the daemon to take 50+ seconds to start instead of ~1s.
|
|
688
|
+
const bunBinDir = join(homedir(), ".bun", "bin");
|
|
689
|
+
const basePath =
|
|
690
|
+
process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
625
691
|
const daemonEnv: Record<string, string> = {
|
|
626
692
|
HOME: process.env.HOME || homedir(),
|
|
627
|
-
PATH:
|
|
628
|
-
process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
693
|
+
PATH: `${bunBinDir}:${basePath}`,
|
|
629
694
|
VELLUM_DAEMON_TCP_ENABLED: "1",
|
|
630
695
|
};
|
|
631
696
|
// Forward optional config env vars the daemon may need
|
|
@@ -647,14 +712,18 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
|
|
|
647
712
|
}
|
|
648
713
|
}
|
|
649
714
|
|
|
715
|
+
// Use fd inheritance instead of pipes so the daemon's stdout/stderr
|
|
716
|
+
// survive after the parent (hatch) exits. Bun does not ignore SIGPIPE,
|
|
717
|
+
// so piped stdio would kill the daemon on its first write after the
|
|
718
|
+
// parent closes.
|
|
650
719
|
const daemonLogFd = openLogFile("hatch.log");
|
|
651
720
|
const child = spawn(daemonBinary, [], {
|
|
652
721
|
cwd: dirname(daemonBinary),
|
|
653
722
|
detached: true,
|
|
654
|
-
stdio: ["ignore",
|
|
723
|
+
stdio: ["ignore", daemonLogFd, daemonLogFd],
|
|
655
724
|
env: daemonEnv,
|
|
656
725
|
});
|
|
657
|
-
|
|
726
|
+
if (typeof daemonLogFd === "number") closeSync(daemonLogFd);
|
|
658
727
|
child.unref();
|
|
659
728
|
const daemonPid = child.pid;
|
|
660
729
|
|
|
@@ -665,6 +734,13 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
|
|
|
665
734
|
}
|
|
666
735
|
}
|
|
667
736
|
|
|
737
|
+
// Ensure bun is available for runtime features (browser, skills install)
|
|
738
|
+
// Runs after daemon-reuse checks so the fast attach path is not blocked
|
|
739
|
+
// by a potentially slow bun install when the daemon is already alive.
|
|
740
|
+
if (daemonAlive) {
|
|
741
|
+
ensureBunInstalled();
|
|
742
|
+
}
|
|
743
|
+
|
|
668
744
|
// Wait for socket at ~/.vellum/vellum.sock (up to 60s — fresh installs
|
|
669
745
|
// may need 30-60s for Qdrant download, migrations, and first-time init)
|
|
670
746
|
let socketReady = await waitForSocketFile(socketFile, 60000);
|
|
@@ -753,74 +829,16 @@ export async function startGateway(
|
|
|
753
829
|
process.env.GATEWAY_DEFAULT_ASSISTANT_ID ||
|
|
754
830
|
loadLatestAssistant()?.assistantId;
|
|
755
831
|
|
|
756
|
-
// Read the bearer token so the gateway can authenticate proxied requests
|
|
757
|
-
// (e.g. from paired iOS devices). Respect VELLUM_HTTP_TOKEN_PATH and
|
|
758
|
-
// BASE_DATA_DIR for consistency with gateway/config.ts and the daemon.
|
|
759
|
-
const httpTokenPath =
|
|
760
|
-
process.env.VELLUM_HTTP_TOKEN_PATH ??
|
|
761
|
-
join(
|
|
762
|
-
process.env.BASE_DATA_DIR?.trim() || homedir(),
|
|
763
|
-
".vellum",
|
|
764
|
-
"http-token",
|
|
765
|
-
);
|
|
766
|
-
let runtimeProxyBearerToken: string | undefined;
|
|
767
|
-
try {
|
|
768
|
-
const tok = readFileSync(httpTokenPath, "utf-8").trim();
|
|
769
|
-
if (tok) runtimeProxyBearerToken = tok;
|
|
770
|
-
} catch {
|
|
771
|
-
// Token file doesn't exist yet — daemon hasn't written it.
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
// If no token is available (first startup — daemon hasn't written it yet),
|
|
775
|
-
// poll for the file to appear. On fresh installs the daemon may take 60s+
|
|
776
|
-
// for Qdrant download, migrations, and first-time init. Starting the
|
|
777
|
-
// gateway without auth is a security risk since the config is loaded once
|
|
778
|
-
// at startup and never reloads, so we fail rather than silently disabling auth.
|
|
779
|
-
if (!runtimeProxyBearerToken) {
|
|
780
|
-
console.log(" Waiting for bearer token file...");
|
|
781
|
-
const maxWait = 60000;
|
|
782
|
-
const pollInterval = 500;
|
|
783
|
-
const start = Date.now();
|
|
784
|
-
const pidFile = join(
|
|
785
|
-
process.env.BASE_DATA_DIR?.trim() || homedir(),
|
|
786
|
-
".vellum",
|
|
787
|
-
"vellum.pid",
|
|
788
|
-
);
|
|
789
|
-
while (Date.now() - start < maxWait) {
|
|
790
|
-
await new Promise((r) => setTimeout(r, pollInterval));
|
|
791
|
-
try {
|
|
792
|
-
const tok = readFileSync(httpTokenPath, "utf-8").trim();
|
|
793
|
-
if (tok) {
|
|
794
|
-
runtimeProxyBearerToken = tok;
|
|
795
|
-
break;
|
|
796
|
-
}
|
|
797
|
-
} catch {
|
|
798
|
-
// File still doesn't exist, keep polling.
|
|
799
|
-
}
|
|
800
|
-
// Check if the daemon process is still alive — no point waiting if it crashed
|
|
801
|
-
try {
|
|
802
|
-
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
803
|
-
if (pid) process.kill(pid, 0); // throws if process doesn't exist
|
|
804
|
-
} catch {
|
|
805
|
-
break; // daemon process is gone
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
if (!runtimeProxyBearerToken) {
|
|
811
|
-
throw new Error(
|
|
812
|
-
`Bearer token file not found at ${httpTokenPath} after 60s.\n` +
|
|
813
|
-
" The gateway cannot start without authentication — this would leave the proxy permanently unauthenticated.\n" +
|
|
814
|
-
" Ensure the daemon is running and has written the token file, or set VELLUM_HTTP_TOKEN_PATH to the correct path.",
|
|
815
|
-
);
|
|
816
|
-
}
|
|
817
|
-
|
|
818
832
|
const gatewayEnv: Record<string, string> = {
|
|
819
833
|
...(process.env as Record<string, string>),
|
|
820
834
|
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
821
835
|
GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "true",
|
|
822
|
-
RUNTIME_PROXY_BEARER_TOKEN: runtimeProxyBearerToken,
|
|
823
836
|
RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
|
|
837
|
+
// Skip the drain window for locally-launched gateways — there is no load
|
|
838
|
+
// balancer draining connections, so waiting serves no purpose and causes
|
|
839
|
+
// `vellum sleep` to SIGKILL the gateway when the CLI timeout is shorter
|
|
840
|
+
// than the drain window. Respect an explicit env override.
|
|
841
|
+
GATEWAY_SHUTDOWN_DRAIN_MS: process.env.GATEWAY_SHUTDOWN_DRAIN_MS || "0",
|
|
824
842
|
};
|
|
825
843
|
|
|
826
844
|
if (process.env.GATEWAY_UNMAPPED_POLICY) {
|
|
@@ -856,13 +874,15 @@ export async function startGateway(
|
|
|
856
874
|
);
|
|
857
875
|
}
|
|
858
876
|
|
|
877
|
+
// Use fd inheritance (not pipes) so the gateway survives after the
|
|
878
|
+
// hatch CLI exits — Bun does not ignore SIGPIPE.
|
|
859
879
|
const gatewayLogFd = openLogFile("hatch.log");
|
|
860
880
|
gateway = spawn(gatewayBinary, [], {
|
|
861
881
|
detached: true,
|
|
862
|
-
stdio: ["ignore",
|
|
882
|
+
stdio: ["ignore", gatewayLogFd, gatewayLogFd],
|
|
863
883
|
env: gatewayEnv,
|
|
864
884
|
});
|
|
865
|
-
|
|
885
|
+
if (typeof gatewayLogFd === "number") closeSync(gatewayLogFd);
|
|
866
886
|
} else {
|
|
867
887
|
// Source tree / bunx: resolve the gateway source directory and run via bun.
|
|
868
888
|
const gatewayDir = resolveGatewayDir();
|
|
@@ -873,10 +893,10 @@ export async function startGateway(
|
|
|
873
893
|
gateway = spawn("bun", bunArgs, {
|
|
874
894
|
cwd: gatewayDir,
|
|
875
895
|
detached: true,
|
|
876
|
-
stdio: ["ignore",
|
|
896
|
+
stdio: ["ignore", gwLogFd, gwLogFd],
|
|
877
897
|
env: gatewayEnv,
|
|
878
898
|
});
|
|
879
|
-
|
|
899
|
+
if (typeof gwLogFd === "number") closeSync(gwLogFd);
|
|
880
900
|
if (watch) {
|
|
881
901
|
console.log(" Gateway started in watch mode (bun --watch)");
|
|
882
902
|
}
|
|
@@ -934,6 +954,5 @@ export async function stopLocalProcesses(): Promise<void> {
|
|
|
934
954
|
await stopProcessByPidFile(daemonPidFile, "daemon", [socketFile]);
|
|
935
955
|
|
|
936
956
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
937
|
-
await stopProcessByPidFile(gatewayPidFile, "gateway");
|
|
938
|
-
|
|
957
|
+
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
|
|
939
958
|
}
|
package/src/lib/process.ts
CHANGED
|
@@ -45,12 +45,13 @@ export function isProcessAlive(pidFile: string): {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Stop a process by PID: SIGTERM, wait up to
|
|
48
|
+
* Stop a process by PID: SIGTERM, wait up to `timeoutMs`, then SIGKILL if still alive.
|
|
49
49
|
* Returns true if the process was stopped, false if it wasn't alive.
|
|
50
50
|
*/
|
|
51
51
|
export async function stopProcess(
|
|
52
52
|
pid: number,
|
|
53
53
|
label: string,
|
|
54
|
+
timeoutMs: number = 2000,
|
|
54
55
|
): Promise<boolean> {
|
|
55
56
|
try {
|
|
56
57
|
process.kill(pid, 0);
|
|
@@ -61,7 +62,7 @@ export async function stopProcess(
|
|
|
61
62
|
console.log(`Stopping ${label} (pid ${pid})...`);
|
|
62
63
|
process.kill(pid, "SIGTERM");
|
|
63
64
|
|
|
64
|
-
const deadline = Date.now() +
|
|
65
|
+
const deadline = Date.now() + timeoutMs;
|
|
65
66
|
while (Date.now() < deadline) {
|
|
66
67
|
try {
|
|
67
68
|
process.kill(pid, 0);
|
|
@@ -90,6 +91,7 @@ export async function stopProcessByPidFile(
|
|
|
90
91
|
pidFile: string,
|
|
91
92
|
label: string,
|
|
92
93
|
cleanupFiles?: string[],
|
|
94
|
+
timeoutMs?: number,
|
|
93
95
|
): Promise<boolean> {
|
|
94
96
|
const { alive, pid } = isProcessAlive(pidFile);
|
|
95
97
|
|
|
@@ -129,7 +131,7 @@ export async function stopProcessByPidFile(
|
|
|
129
131
|
return false;
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
const stopped = await stopProcess(pid, label);
|
|
134
|
+
const stopped = await stopProcess(pid, label, timeoutMs);
|
|
133
135
|
|
|
134
136
|
try {
|
|
135
137
|
unlinkSync(pidFile);
|
package/src/lib/step-runner.ts
CHANGED
|
@@ -1,46 +1,5 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
|
|
3
|
-
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4
|
-
|
|
5
|
-
interface Step {
|
|
6
|
-
name: string;
|
|
7
|
-
run: () => Promise<void>;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function clearLine(): void {
|
|
11
|
-
process.stdout.write("\r\x1b[K");
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function showSpinner(name: string): NodeJS.Timeout {
|
|
15
|
-
let frameIndex = 0;
|
|
16
|
-
return setInterval(() => {
|
|
17
|
-
clearLine();
|
|
18
|
-
process.stdout.write(` ${SPINNER_FRAMES[frameIndex]} ${name}...`);
|
|
19
|
-
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
|
|
20
|
-
}, 80);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function runSteps(steps: Step[]): Promise<void> {
|
|
24
|
-
for (let i = 0; i < steps.length; i++) {
|
|
25
|
-
const step = steps[i];
|
|
26
|
-
const label = `[${i + 1}/${steps.length}] ${step.name}`;
|
|
27
|
-
|
|
28
|
-
const spinner = showSpinner(label);
|
|
29
|
-
|
|
30
|
-
try {
|
|
31
|
-
await step.run();
|
|
32
|
-
clearInterval(spinner);
|
|
33
|
-
clearLine();
|
|
34
|
-
console.log(` ✔ ${label}`);
|
|
35
|
-
} catch (error) {
|
|
36
|
-
clearInterval(spinner);
|
|
37
|
-
clearLine();
|
|
38
|
-
console.log(` ✖ ${label}`);
|
|
39
|
-
throw error;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
3
|
export function exec(
|
|
45
4
|
command: string,
|
|
46
5
|
args: string[],
|