@vellumai/cli 0.8.12-staging.2 → 0.9.0-staging.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 +49 -56
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
- package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
- package/package.json +3 -3
- package/src/__tests__/assistant-config.test.ts +1 -2
- package/src/__tests__/device-id.test.ts +6 -14
- package/src/__tests__/helpers/os-mock.ts +27 -0
- package/src/__tests__/login-loopback.test.ts +71 -0
- package/src/__tests__/multi-local.test.ts +2 -10
- package/src/__tests__/nginx-ingress-command.test.ts +69 -0
- package/src/__tests__/nginx-ingress.test.ts +401 -0
- package/src/__tests__/sleep.test.ts +4 -0
- package/src/__tests__/teleport.test.ts +6 -9
- package/src/__tests__/tunnel.test.ts +164 -0
- package/src/__tests__/wake.test.ts +15 -4
- package/src/__tests__/workos-pkce.test.ts +314 -0
- package/src/commands/flags.ts +1 -22
- package/src/commands/hatch.ts +90 -9
- package/src/commands/login.ts +123 -59
- package/src/commands/nginx-ingress.ts +291 -0
- package/src/commands/rollback.ts +0 -6
- package/src/commands/sleep.ts +17 -0
- package/src/commands/teleport.ts +23 -36
- package/src/commands/tunnel.ts +69 -11
- package/src/commands/upgrade.ts +0 -2
- package/src/commands/wake.ts +7 -5
- package/src/commands/workflows.ts +301 -0
- package/src/index.ts +8 -0
- package/src/lib/arg-utils.ts +48 -0
- package/src/lib/assistant-client.ts +2 -0
- package/src/lib/assistant-config.ts +0 -7
- package/src/lib/cloudflare-tunnel.ts +15 -2
- package/src/lib/docker.ts +103 -49
- package/src/lib/feature-flags.test.ts +157 -0
- package/src/lib/feature-flags.ts +38 -0
- package/src/lib/hatch-local.ts +0 -1
- package/src/lib/local.ts +5 -0
- package/src/lib/nginx-ingress.ts +574 -0
- package/src/lib/ngrok.ts +26 -4
- package/src/lib/platform-client.ts +0 -1
- package/src/lib/retire-local.ts +5 -0
- package/src/lib/statefulset.ts +73 -21
- package/src/lib/sync-cloud-assistants.ts +4 -17
- package/src/lib/upgrade-lifecycle.ts +1 -2
- package/src/lib/workos-pkce.ts +160 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import {
|
|
2
|
+
execFileSync,
|
|
3
|
+
spawn,
|
|
4
|
+
spawnSync,
|
|
5
|
+
type ChildProcess,
|
|
6
|
+
} from "node:child_process";
|
|
7
|
+
import {
|
|
8
|
+
closeSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
openSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { GATEWAY_PORT } from "./constants.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* CLI-managed nginx reverse proxy that fronts the gateway for remote web
|
|
23
|
+
* ingress: browser → tunnel (TLS) → nginx@127.0.0.1 → gateway@127.0.0.1.
|
|
24
|
+
*
|
|
25
|
+
* While this proxy is running, `vellum tunnel` targets nginx's loopback listen
|
|
26
|
+
* port instead of the gateway port.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_NGINX_INGRESS_PORT = 7840;
|
|
30
|
+
const _require = createRequire(import.meta.url);
|
|
31
|
+
|
|
32
|
+
/** Listen port for nginx ingress, from VELLUM_NGINX_INGRESS_PORT. */
|
|
33
|
+
export function getNginxIngressPort(): number {
|
|
34
|
+
const raw = process.env.VELLUM_NGINX_INGRESS_PORT;
|
|
35
|
+
if (!raw) return DEFAULT_NGINX_INGRESS_PORT;
|
|
36
|
+
const value = Number(raw);
|
|
37
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
38
|
+
throw new Error("VELLUM_NGINX_INGRESS_PORT must be a valid TCP port");
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface IngressPaths {
|
|
44
|
+
/** nginx prefix dir; conf, pidfile, and temp dirs live here. */
|
|
45
|
+
dir: string;
|
|
46
|
+
confPath: string;
|
|
47
|
+
pidPath: string;
|
|
48
|
+
logPath: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getIngressPaths(workspaceDir: string): IngressPaths {
|
|
52
|
+
const dir = join(workspaceDir, "data", "ingress");
|
|
53
|
+
return {
|
|
54
|
+
dir,
|
|
55
|
+
confPath: join(dir, "nginx.conf"),
|
|
56
|
+
pidPath: join(dir, "nginx.pid"),
|
|
57
|
+
logPath: join(workspaceDir, "data", "logs", "nginx-ingress.log"),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getConfigPath(workspaceDir: string): string {
|
|
62
|
+
return join(workspaceDir, "config.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadRawConfig(workspaceDir: string): Record<string, unknown> {
|
|
66
|
+
const configPath = getConfigPath(workspaceDir);
|
|
67
|
+
if (!existsSync(configPath)) return {};
|
|
68
|
+
return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
|
|
69
|
+
string,
|
|
70
|
+
unknown
|
|
71
|
+
>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function saveRawConfig(
|
|
75
|
+
workspaceDir: string,
|
|
76
|
+
config: Record<string, unknown>,
|
|
77
|
+
): void {
|
|
78
|
+
const configPath = getConfigPath(workspaceDir);
|
|
79
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
80
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Locate the pre-built @vellumai/web dist directory.
|
|
85
|
+
*
|
|
86
|
+
* Resolution order:
|
|
87
|
+
* 1. npm-installed package — require.resolve('@vellumai/web/package.json')
|
|
88
|
+
* 2. Source checkout — walk up from cli/ to find apps/web/dist/
|
|
89
|
+
*/
|
|
90
|
+
export function findWebDistDir(): string | null {
|
|
91
|
+
try {
|
|
92
|
+
const pkgPath = _require.resolve("@vellumai/web/package.json");
|
|
93
|
+
const distDir = join(dirname(pkgPath), "dist");
|
|
94
|
+
if (existsSync(join(distDir, "index.html"))) {
|
|
95
|
+
return distDir;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Package not installed; try source checkout.
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let dir = import.meta.dir;
|
|
102
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
103
|
+
const candidate = join(dir, "apps", "web", "dist", "index.html");
|
|
104
|
+
if (existsSync(candidate)) {
|
|
105
|
+
return dirname(candidate);
|
|
106
|
+
}
|
|
107
|
+
const parent = dirname(dir);
|
|
108
|
+
if (parent === dir) break;
|
|
109
|
+
dir = parent;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function nginxQuoted(value: string, label: string): string {
|
|
115
|
+
if (/[\u0000-\u001f\u007f]/.test(value)) {
|
|
116
|
+
throw new Error(`${label} contains a control character`);
|
|
117
|
+
}
|
|
118
|
+
return `"${value
|
|
119
|
+
.replace(/\\/g, "\\\\")
|
|
120
|
+
.replace(/"/g, '\\"')
|
|
121
|
+
.replace(/\$/g, "\\$")}"`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function nginxDirPath(dir: string): string {
|
|
125
|
+
return dir.endsWith("/") ? dir : `${dir}/`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function gatewayProxyBlock(gatewayPort: number): string {
|
|
129
|
+
return ` proxy_pass http://127.0.0.1:${gatewayPort};
|
|
130
|
+
proxy_http_version 1.1;
|
|
131
|
+
proxy_request_buffering off;
|
|
132
|
+
proxy_buffering off;
|
|
133
|
+
proxy_read_timeout 1h;
|
|
134
|
+
proxy_set_header Host $host;
|
|
135
|
+
proxy_set_header X-Vellum-Edge-Forwarded "1";
|
|
136
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
137
|
+
proxy_set_header Connection $connection_upgrade;`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface RemoteWebIngressOptions {
|
|
141
|
+
webDistDir: string;
|
|
142
|
+
indexHtmlPath?: string;
|
|
143
|
+
config?: Record<string, unknown>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function remoteWebIngressConfig(
|
|
147
|
+
config: Record<string, unknown> | undefined,
|
|
148
|
+
): Record<string, unknown> {
|
|
149
|
+
return {
|
|
150
|
+
mode: "remote-gateway",
|
|
151
|
+
apiBaseUrl: "/v1",
|
|
152
|
+
platformDisabled: true,
|
|
153
|
+
disablePlatform: true,
|
|
154
|
+
...config,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function safeScriptJson(value: unknown): string {
|
|
159
|
+
return JSON.stringify(value)
|
|
160
|
+
.replace(/</g, "\\u003c")
|
|
161
|
+
.replace(/>/g, "\\u003e");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function buildRemoteWebIndexHtml(
|
|
165
|
+
rawHtml: string,
|
|
166
|
+
config: Record<string, unknown>,
|
|
167
|
+
): string {
|
|
168
|
+
const script = `<script>window.__VELLUM_CONFIG__=${safeScriptJson(config)}</script>`;
|
|
169
|
+
if (rawHtml.includes("</head>")) {
|
|
170
|
+
return rawHtml.replace("</head>", `${script}</head>`);
|
|
171
|
+
}
|
|
172
|
+
return `${script}${rawHtml}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build the nginx config that forwards tunnel web traffic to the gateway.
|
|
177
|
+
*/
|
|
178
|
+
export function buildIngressNginxConfig(opts: {
|
|
179
|
+
gatewayPort: number;
|
|
180
|
+
listenPort: number;
|
|
181
|
+
remoteWebIngress?: RemoteWebIngressOptions;
|
|
182
|
+
}): string {
|
|
183
|
+
const proxyBlock = gatewayProxyBlock(opts.gatewayPort);
|
|
184
|
+
const remoteWebIngress = opts.remoteWebIngress;
|
|
185
|
+
const serverLocations = remoteWebIngress
|
|
186
|
+
? buildRemoteWebIngressLocations({
|
|
187
|
+
gatewayPort: opts.gatewayPort,
|
|
188
|
+
webDistDir: remoteWebIngress.webDistDir,
|
|
189
|
+
indexHtmlPath: remoteWebIngress.indexHtmlPath,
|
|
190
|
+
config: remoteWebIngressConfig(remoteWebIngress.config),
|
|
191
|
+
})
|
|
192
|
+
: ` location / {
|
|
193
|
+
${proxyBlock}
|
|
194
|
+
}`;
|
|
195
|
+
|
|
196
|
+
return `
|
|
197
|
+
worker_processes 1;
|
|
198
|
+
error_log stderr;
|
|
199
|
+
pid nginx.pid;
|
|
200
|
+
|
|
201
|
+
events {}
|
|
202
|
+
|
|
203
|
+
http {
|
|
204
|
+
access_log off;
|
|
205
|
+
default_type application/octet-stream;
|
|
206
|
+
|
|
207
|
+
types {
|
|
208
|
+
application/javascript js mjs;
|
|
209
|
+
application/json json map;
|
|
210
|
+
application/wasm wasm;
|
|
211
|
+
font/woff woff;
|
|
212
|
+
font/woff2 woff2;
|
|
213
|
+
image/gif gif;
|
|
214
|
+
image/jpeg jpeg jpg;
|
|
215
|
+
image/png png;
|
|
216
|
+
image/svg+xml svg svgz;
|
|
217
|
+
image/webp webp;
|
|
218
|
+
image/x-icon ico;
|
|
219
|
+
text/css css;
|
|
220
|
+
text/html html htm;
|
|
221
|
+
text/plain txt;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
map $http_upgrade $connection_upgrade {
|
|
225
|
+
default upgrade;
|
|
226
|
+
"" close;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
server {
|
|
230
|
+
listen 127.0.0.1:${opts.listenPort};
|
|
231
|
+
client_max_body_size 512m;
|
|
232
|
+
|
|
233
|
+
${serverLocations}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function buildRemoteWebIngressLocations(opts: {
|
|
240
|
+
gatewayPort: number;
|
|
241
|
+
webDistDir: string;
|
|
242
|
+
indexHtmlPath?: string;
|
|
243
|
+
config: Record<string, unknown>;
|
|
244
|
+
}): string {
|
|
245
|
+
const proxyBlock = gatewayProxyBlock(opts.gatewayPort);
|
|
246
|
+
const webDistDir = nginxDirPath(opts.webDistDir);
|
|
247
|
+
const webAssetsDir = join(opts.webDistDir, "assets");
|
|
248
|
+
const indexHtmlPath =
|
|
249
|
+
opts.indexHtmlPath ?? join(opts.webDistDir, "index.html");
|
|
250
|
+
const configJson = JSON.stringify(opts.config);
|
|
251
|
+
|
|
252
|
+
return ` location = /auth/token { return 404; }
|
|
253
|
+
location = /auth/token/ { return 404; }
|
|
254
|
+
location = /v1/pair { return 404; }
|
|
255
|
+
location = /v1/pair/ { return 404; }
|
|
256
|
+
location = /v1/pair/web-init { return 404; }
|
|
257
|
+
location = /v1/pair/web-init/ { return 404; }
|
|
258
|
+
location = /v1/devices { return 404; }
|
|
259
|
+
location = /v1/devices/ { return 404; }
|
|
260
|
+
location = /v1/devices/revoke { return 404; }
|
|
261
|
+
location = /v1/devices/revoke/ { return 404; }
|
|
262
|
+
location = /v1/guardian/init { return 404; }
|
|
263
|
+
location = /v1/guardian/init/ { return 404; }
|
|
264
|
+
location = /v1/guardian/reset-bootstrap { return 404; }
|
|
265
|
+
location = /v1/guardian/reset-bootstrap/ { return 404; }
|
|
266
|
+
location ^~ /assistant/__local/ { return 404; }
|
|
267
|
+
location ^~ /assistant/__gateway/ { return 404; }
|
|
268
|
+
|
|
269
|
+
location = /healthz {
|
|
270
|
+
${proxyBlock}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
location ^~ /v1/ {
|
|
274
|
+
${proxyBlock}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
location = /assistant {
|
|
278
|
+
return 302 /assistant/;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
location = /assistant/ {
|
|
282
|
+
rewrite ^ /assistant/__remote-index.html last;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
location = /assistant/index.html {
|
|
286
|
+
rewrite ^ /assistant/__remote-index.html last;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
location = /assistant/__remote-index.html {
|
|
290
|
+
internal;
|
|
291
|
+
alias ${nginxQuoted(indexHtmlPath, "remote web ingress index path")};
|
|
292
|
+
add_header Cache-Control "no-store";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
location = /assistant/__config {
|
|
296
|
+
default_type application/json;
|
|
297
|
+
add_header Cache-Control "no-store";
|
|
298
|
+
return 200 ${nginxQuoted(configJson, "remote web ingress config")};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
location ^~ /assistant/assets/ {
|
|
302
|
+
alias ${nginxQuoted(nginxDirPath(webAssetsDir), "web assets path")};
|
|
303
|
+
try_files $uri =404;
|
|
304
|
+
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
location ^~ /assistant/ {
|
|
308
|
+
alias ${nginxQuoted(webDistDir, "web dist path")};
|
|
309
|
+
try_files $uri $uri/ /assistant/__remote-index.html;
|
|
310
|
+
add_header Cache-Control "no-store";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
location = / {
|
|
314
|
+
return 302 /assistant/;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
location / {
|
|
318
|
+
return 404;
|
|
319
|
+
}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function nginxBin(): string {
|
|
323
|
+
return process.env.NGINX_BIN || "nginx";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Check whether nginx is installed and accessible.
|
|
328
|
+
* Returns the version string if installed, null otherwise.
|
|
329
|
+
* (nginx prints its version to stderr.)
|
|
330
|
+
*/
|
|
331
|
+
export function getNginxVersion(): string | null {
|
|
332
|
+
const result = spawnSync(nginxBin(), ["-v"], {
|
|
333
|
+
encoding: "utf-8",
|
|
334
|
+
timeout: 5_000,
|
|
335
|
+
});
|
|
336
|
+
if (result.error || result.status !== 0) return null;
|
|
337
|
+
const output = `${result.stderr || ""}${result.stdout || ""}`.trim();
|
|
338
|
+
return output || null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/*
|
|
342
|
+
* PID handling is deliberately self-contained rather than reusing the
|
|
343
|
+
* process.ts helpers: stopProcessByPidFile's isVellumProcess() guard only
|
|
344
|
+
* matches command lines containing a vellum path, which fails for a custom
|
|
345
|
+
* VELLUM_WORKSPACE_DIR and would silently leave nginx running (the same
|
|
346
|
+
* reason local.ts kills ngrok directly). This module is also imported by
|
|
347
|
+
* sleep/retire, whose tests mock.module() process.js process-globally —
|
|
348
|
+
* depending on it here would couple this lib's behavior to those mocks.
|
|
349
|
+
*/
|
|
350
|
+
|
|
351
|
+
function readPidFile(pidPath: string): number | null {
|
|
352
|
+
try {
|
|
353
|
+
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
354
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isPidAlive(pid: number): boolean {
|
|
361
|
+
try {
|
|
362
|
+
process.kill(pid, 0);
|
|
363
|
+
return true;
|
|
364
|
+
} catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Check whether a PID belongs to this ingress nginx process.
|
|
371
|
+
*
|
|
372
|
+
* Matching only the executable name is not enough: a stale pidfile can point
|
|
373
|
+
* at a system nginx or another assistant's ingress after PID reuse.
|
|
374
|
+
*/
|
|
375
|
+
function isIngressNginxProcess(pid: number, paths: IngressPaths): boolean {
|
|
376
|
+
try {
|
|
377
|
+
const output = execFileSync(
|
|
378
|
+
"ps",
|
|
379
|
+
["-ww", "-p", String(pid), "-o", "command="],
|
|
380
|
+
{
|
|
381
|
+
encoding: "utf-8",
|
|
382
|
+
timeout: 3000,
|
|
383
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
384
|
+
},
|
|
385
|
+
).trim();
|
|
386
|
+
return (
|
|
387
|
+
/nginx/.test(output) &&
|
|
388
|
+
output.includes(paths.dir) &&
|
|
389
|
+
output.includes(paths.confPath)
|
|
390
|
+
);
|
|
391
|
+
} catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The ingress nginx PID when it is recorded and alive, null otherwise. */
|
|
397
|
+
export function getIngressPid(workspaceDir: string): number | null {
|
|
398
|
+
const paths = getIngressPaths(workspaceDir);
|
|
399
|
+
const pid = readPidFile(paths.pidPath);
|
|
400
|
+
return pid !== null && isPidAlive(pid) && isIngressNginxProcess(pid, paths)
|
|
401
|
+
? pid
|
|
402
|
+
: null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function isIngressRunning(workspaceDir: string): boolean {
|
|
406
|
+
return getIngressPid(workspaceDir) !== null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
interface IngressState {
|
|
410
|
+
listenPort: number;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function readIngressState(workspaceDir: string): IngressState | null {
|
|
414
|
+
const config = loadRawConfig(workspaceDir);
|
|
415
|
+
const ingress = config.ingress as Record<string, unknown> | undefined;
|
|
416
|
+
const nginx = ingress?.nginx as Record<string, unknown> | undefined;
|
|
417
|
+
const listenPort = nginx?.listenPort;
|
|
418
|
+
if (typeof listenPort !== "number") return null;
|
|
419
|
+
return { listenPort };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function saveIngressState(workspaceDir: string, state: IngressState): void {
|
|
423
|
+
const config = loadRawConfig(workspaceDir);
|
|
424
|
+
const ingress = (config.ingress ?? {}) as Record<string, unknown>;
|
|
425
|
+
ingress.nginx = { listenPort: state.listenPort };
|
|
426
|
+
config.ingress = ingress;
|
|
427
|
+
saveRawConfig(workspaceDir, config);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function clearIngressState(workspaceDir: string): void {
|
|
431
|
+
const config = loadRawConfig(workspaceDir);
|
|
432
|
+
const ingress = config.ingress as Record<string, unknown> | undefined;
|
|
433
|
+
if (!ingress) return;
|
|
434
|
+
delete ingress.nginx;
|
|
435
|
+
saveRawConfig(workspaceDir, config);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function clearStoppedIngress(workspaceDir: string, pidPath: string): void {
|
|
439
|
+
clearIngressState(workspaceDir);
|
|
440
|
+
rmSync(pidPath, { force: true });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Write the nginx config and spawn nginx detached (same idiom as the ngrok
|
|
445
|
+
* spawn in ngrok.ts: stdout/stderr to a log file, fd closed after spawn,
|
|
446
|
+
* caller unrefs). nginx runs with `daemon off` so the spawned process is the
|
|
447
|
+
* master; it writes its pid to nginx.pid under the prefix dir.
|
|
448
|
+
*/
|
|
449
|
+
export function startIngressNginx(opts: {
|
|
450
|
+
workspaceDir: string;
|
|
451
|
+
gatewayPort: number;
|
|
452
|
+
listenPort: number;
|
|
453
|
+
remoteWebIngress?: RemoteWebIngressOptions;
|
|
454
|
+
}): ChildProcess {
|
|
455
|
+
const paths = getIngressPaths(opts.workspaceDir);
|
|
456
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
457
|
+
mkdirSync(join(opts.workspaceDir, "data", "logs"), { recursive: true });
|
|
458
|
+
const remoteWebIngress = opts.remoteWebIngress
|
|
459
|
+
? {
|
|
460
|
+
...opts.remoteWebIngress,
|
|
461
|
+
config: remoteWebIngressConfig(opts.remoteWebIngress.config),
|
|
462
|
+
indexHtmlPath: join(paths.dir, "assistant-index.html"),
|
|
463
|
+
}
|
|
464
|
+
: undefined;
|
|
465
|
+
if (remoteWebIngress) {
|
|
466
|
+
const rawIndexHtml = readFileSync(
|
|
467
|
+
join(remoteWebIngress.webDistDir, "index.html"),
|
|
468
|
+
"utf-8",
|
|
469
|
+
);
|
|
470
|
+
writeFileSync(
|
|
471
|
+
remoteWebIngress.indexHtmlPath,
|
|
472
|
+
buildRemoteWebIndexHtml(rawIndexHtml, remoteWebIngress.config),
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
writeFileSync(
|
|
476
|
+
paths.confPath,
|
|
477
|
+
buildIngressNginxConfig({
|
|
478
|
+
gatewayPort: opts.gatewayPort,
|
|
479
|
+
listenPort: opts.listenPort,
|
|
480
|
+
remoteWebIngress,
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
const fd = openSync(paths.logPath, "a");
|
|
485
|
+
const child = spawn(
|
|
486
|
+
nginxBin(),
|
|
487
|
+
["-p", paths.dir, "-c", paths.confPath, "-g", "daemon off;"],
|
|
488
|
+
{ detached: true, stdio: ["ignore", fd, fd] },
|
|
489
|
+
);
|
|
490
|
+
closeSync(fd);
|
|
491
|
+
|
|
492
|
+
saveIngressState(opts.workspaceDir, { listenPort: opts.listenPort });
|
|
493
|
+
return child;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const STOP_TIMEOUT_MS = 2_000;
|
|
497
|
+
|
|
498
|
+
async function waitForPidExit(
|
|
499
|
+
pid: number,
|
|
500
|
+
timeoutMs: number,
|
|
501
|
+
): Promise<boolean> {
|
|
502
|
+
const deadline = Date.now() + timeoutMs;
|
|
503
|
+
while (Date.now() < deadline) {
|
|
504
|
+
if (!isPidAlive(pid)) return true;
|
|
505
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
506
|
+
}
|
|
507
|
+
return !isPidAlive(pid);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Stop a running ingress nginx via its pidfile and clear the recorded state.
|
|
512
|
+
* Returns true if a process was stopped.
|
|
513
|
+
*
|
|
514
|
+
* Verifies the PID still belongs to this ingress nginx before killing to avoid
|
|
515
|
+
* hitting an unrelated process if the OS has reused the PID. SIGTERM is nginx
|
|
516
|
+
* fast shutdown; escalate to SIGKILL if it doesn't exit within the timeout.
|
|
517
|
+
*/
|
|
518
|
+
export async function stopIngressNginx(workspaceDir: string): Promise<boolean> {
|
|
519
|
+
const paths = getIngressPaths(workspaceDir);
|
|
520
|
+
|
|
521
|
+
const pid = readPidFile(paths.pidPath);
|
|
522
|
+
if (pid === null || !isPidAlive(pid) || !isIngressNginxProcess(pid, paths)) {
|
|
523
|
+
clearStoppedIngress(workspaceDir, paths.pidPath);
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
process.kill(pid, "SIGTERM");
|
|
529
|
+
if (!(await waitForPidExit(pid, STOP_TIMEOUT_MS))) {
|
|
530
|
+
try {
|
|
531
|
+
process.kill(pid, "SIGKILL");
|
|
532
|
+
} catch {
|
|
533
|
+
if (!isPidAlive(pid)) {
|
|
534
|
+
clearStoppedIngress(workspaceDir, paths.pidPath);
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
if (!(await waitForPidExit(pid, STOP_TIMEOUT_MS))) {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
} catch {
|
|
544
|
+
if (!isPidAlive(pid)) {
|
|
545
|
+
clearStoppedIngress(workspaceDir, paths.pidPath);
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
clearStoppedIngress(workspaceDir, paths.pidPath);
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Resolve the local port a tunnel should target: the nginx ingress when it is
|
|
557
|
+
* recorded AND its process is alive, otherwise the gateway port directly
|
|
558
|
+
* (unchanged behavior when the proxy is not running).
|
|
559
|
+
*/
|
|
560
|
+
export function resolveTunnelTargetPort(
|
|
561
|
+
workspaceDir: string,
|
|
562
|
+
gatewayPort: number = GATEWAY_PORT,
|
|
563
|
+
opts: { preferNginxIngress?: boolean } = {},
|
|
564
|
+
): { port: number; viaIngress: boolean } {
|
|
565
|
+
if (opts.preferNginxIngress === false) {
|
|
566
|
+
return { port: gatewayPort, viaIngress: false };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const state = readIngressState(workspaceDir);
|
|
570
|
+
if (state && isIngressRunning(workspaceDir)) {
|
|
571
|
+
return { port: state.listenPort, viaIngress: true };
|
|
572
|
+
}
|
|
573
|
+
return { port: gatewayPort, viaIngress: false };
|
|
574
|
+
}
|
package/src/lib/ngrok.ts
CHANGED
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
12
|
|
|
13
|
-
import { GATEWAY_PORT } from "./constants";
|
|
13
|
+
import { GATEWAY_PORT } from "./constants.js";
|
|
14
14
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
15
|
+
import { resolveTunnelTargetPort } from "./nginx-ingress.js";
|
|
15
16
|
|
|
16
17
|
function getDefaultWorkspaceDir(): string {
|
|
17
18
|
return (
|
|
@@ -304,11 +305,22 @@ export async function maybeStartNgrokTunnel(
|
|
|
304
305
|
}
|
|
305
306
|
}
|
|
306
307
|
|
|
308
|
+
export interface RunNgrokTunnelOptions {
|
|
309
|
+
/** Gateway port to forward. Defaults to the global GATEWAY_PORT. */
|
|
310
|
+
port?: number;
|
|
311
|
+
/** Workspace directory for config read/write. Defaults to ~/.vellum/workspace. */
|
|
312
|
+
workspaceDir?: string;
|
|
313
|
+
/** Prefer nginx ingress over the gateway port when it is running. */
|
|
314
|
+
preferNginxIngress?: boolean;
|
|
315
|
+
}
|
|
316
|
+
|
|
307
317
|
/**
|
|
308
318
|
* Run the ngrok tunnel workflow: check installation, find or start a tunnel,
|
|
309
319
|
* save the public URL to config, and block until exit or signal.
|
|
310
320
|
*/
|
|
311
|
-
export async function runNgrokTunnel(
|
|
321
|
+
export async function runNgrokTunnel(
|
|
322
|
+
opts: RunNgrokTunnelOptions = {},
|
|
323
|
+
): Promise<void> {
|
|
312
324
|
const version = getNgrokVersion();
|
|
313
325
|
if (!version) {
|
|
314
326
|
console.error("Error: ngrok is not installed.");
|
|
@@ -326,8 +338,18 @@ export async function runNgrokTunnel(): Promise<void> {
|
|
|
326
338
|
|
|
327
339
|
console.log(`Using ${version}`);
|
|
328
340
|
|
|
329
|
-
const
|
|
330
|
-
const
|
|
341
|
+
const workspaceDir = opts.workspaceDir ?? getDefaultWorkspaceDir();
|
|
342
|
+
const gatewayPort = opts.port ?? GATEWAY_PORT;
|
|
343
|
+
const { port, viaIngress } = resolveTunnelTargetPort(
|
|
344
|
+
workspaceDir,
|
|
345
|
+
gatewayPort,
|
|
346
|
+
{ preferNginxIngress: opts.preferNginxIngress === true },
|
|
347
|
+
);
|
|
348
|
+
if (viaIngress) {
|
|
349
|
+
console.log(
|
|
350
|
+
`nginx ingress detected — tunneling to it on 127.0.0.1:${port}.`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
331
353
|
|
|
332
354
|
// Check for an existing ngrok tunnel pointing at the gateway
|
|
333
355
|
const existingUrl = await findExistingTunnel(port);
|
package/src/lib/retire-local.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { basename, dirname, join } from "path";
|
|
|
5
5
|
|
|
6
6
|
import { getDaemonPidPath, loadAllAssistants } from "./assistant-config.js";
|
|
7
7
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
8
|
+
import { stopIngressNginx } from "./nginx-ingress.js";
|
|
8
9
|
import {
|
|
9
10
|
stopOrphanedDaemonProcesses,
|
|
10
11
|
stopProcessByPidFile,
|
|
@@ -80,6 +81,10 @@ export async function retireLocal(
|
|
|
80
81
|
await stopProcessByPidFile(qdrantPidFile, "qdrant", undefined, 5000);
|
|
81
82
|
await stopProcessByPidFile(qdrantLegacyPidFile, "qdrant", undefined, 5000);
|
|
82
83
|
|
|
84
|
+
// Stop the nginx ingress if one is fronting this gateway — it would
|
|
85
|
+
// otherwise be orphaned when the instance directory is archived.
|
|
86
|
+
await stopIngressNginx(join(vellumDir, "workspace"));
|
|
87
|
+
|
|
83
88
|
// If the PID file didn't track a running daemon, scan for orphaned
|
|
84
89
|
// daemon processes that may have been started without writing a PID.
|
|
85
90
|
if (!daemonStopped) {
|