@vellumai/cli 0.8.12-staging.2 → 0.9.0-dev.202606162156.4bad3e5

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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/bun.lock +49 -56
  3. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
  4. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
  5. package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
  7. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
  8. package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
  9. package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
  10. package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
  11. package/package.json +3 -3
  12. package/src/__tests__/assistant-config.test.ts +1 -2
  13. package/src/__tests__/device-id.test.ts +6 -14
  14. package/src/__tests__/helpers/os-mock.ts +27 -0
  15. package/src/__tests__/login-loopback.test.ts +71 -0
  16. package/src/__tests__/multi-local.test.ts +2 -10
  17. package/src/__tests__/nginx-ingress-command.test.ts +69 -0
  18. package/src/__tests__/nginx-ingress.test.ts +403 -0
  19. package/src/__tests__/sleep.test.ts +4 -0
  20. package/src/__tests__/teleport.test.ts +6 -9
  21. package/src/__tests__/tunnel.test.ts +164 -0
  22. package/src/__tests__/wake.test.ts +15 -4
  23. package/src/__tests__/workos-pkce.test.ts +314 -0
  24. package/src/commands/flags.ts +1 -22
  25. package/src/commands/hatch.ts +90 -9
  26. package/src/commands/login.ts +123 -59
  27. package/src/commands/nginx-ingress.ts +291 -0
  28. package/src/commands/rollback.ts +0 -6
  29. package/src/commands/sleep.ts +17 -0
  30. package/src/commands/teleport.ts +23 -36
  31. package/src/commands/tunnel.ts +69 -11
  32. package/src/commands/upgrade.ts +0 -2
  33. package/src/commands/wake.ts +7 -5
  34. package/src/commands/workflows.ts +301 -0
  35. package/src/index.ts +8 -0
  36. package/src/lib/arg-utils.ts +48 -0
  37. package/src/lib/assistant-client.ts +2 -0
  38. package/src/lib/assistant-config.ts +0 -7
  39. package/src/lib/cloudflare-tunnel.ts +15 -2
  40. package/src/lib/docker.ts +103 -49
  41. package/src/lib/feature-flags.test.ts +157 -0
  42. package/src/lib/feature-flags.ts +38 -0
  43. package/src/lib/hatch-local.ts +0 -1
  44. package/src/lib/local.ts +5 -0
  45. package/src/lib/nginx-ingress.ts +576 -0
  46. package/src/lib/ngrok.ts +26 -4
  47. package/src/lib/platform-client.ts +0 -1
  48. package/src/lib/retire-local.ts +5 -0
  49. package/src/lib/statefulset.ts +73 -21
  50. package/src/lib/sync-cloud-assistants.ts +4 -17
  51. package/src/lib/upgrade-lifecycle.ts +1 -2
  52. package/src/lib/workos-pkce.ts +160 -0
@@ -0,0 +1,576 @@
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/remote-web/pairing-challenge { return 404; }
259
+ location = /v1/remote-web/pairing-challenge/ { return 404; }
260
+ location = /v1/devices { return 404; }
261
+ location = /v1/devices/ { return 404; }
262
+ location = /v1/devices/revoke { return 404; }
263
+ location = /v1/devices/revoke/ { return 404; }
264
+ location = /v1/guardian/init { return 404; }
265
+ location = /v1/guardian/init/ { return 404; }
266
+ location = /v1/guardian/reset-bootstrap { return 404; }
267
+ location = /v1/guardian/reset-bootstrap/ { return 404; }
268
+ location ^~ /assistant/__local/ { return 404; }
269
+ location ^~ /assistant/__gateway/ { return 404; }
270
+
271
+ location = /healthz {
272
+ ${proxyBlock}
273
+ }
274
+
275
+ location ^~ /v1/ {
276
+ ${proxyBlock}
277
+ }
278
+
279
+ location = /assistant {
280
+ return 302 /assistant/;
281
+ }
282
+
283
+ location = /assistant/ {
284
+ rewrite ^ /assistant/__remote-index.html last;
285
+ }
286
+
287
+ location = /assistant/index.html {
288
+ rewrite ^ /assistant/__remote-index.html last;
289
+ }
290
+
291
+ location = /assistant/__remote-index.html {
292
+ internal;
293
+ alias ${nginxQuoted(indexHtmlPath, "remote web ingress index path")};
294
+ add_header Cache-Control "no-store";
295
+ }
296
+
297
+ location = /assistant/__config {
298
+ default_type application/json;
299
+ add_header Cache-Control "no-store";
300
+ return 200 ${nginxQuoted(configJson, "remote web ingress config")};
301
+ }
302
+
303
+ location ^~ /assistant/assets/ {
304
+ alias ${nginxQuoted(nginxDirPath(webAssetsDir), "web assets path")};
305
+ try_files $uri =404;
306
+ add_header Cache-Control "public, max-age=31536000, immutable";
307
+ }
308
+
309
+ location ^~ /assistant/ {
310
+ alias ${nginxQuoted(webDistDir, "web dist path")};
311
+ try_files $uri $uri/ /assistant/__remote-index.html;
312
+ add_header Cache-Control "no-store";
313
+ }
314
+
315
+ location = / {
316
+ return 302 /assistant/;
317
+ }
318
+
319
+ location / {
320
+ return 404;
321
+ }`;
322
+ }
323
+
324
+ function nginxBin(): string {
325
+ return process.env.NGINX_BIN || "nginx";
326
+ }
327
+
328
+ /**
329
+ * Check whether nginx is installed and accessible.
330
+ * Returns the version string if installed, null otherwise.
331
+ * (nginx prints its version to stderr.)
332
+ */
333
+ export function getNginxVersion(): string | null {
334
+ const result = spawnSync(nginxBin(), ["-v"], {
335
+ encoding: "utf-8",
336
+ timeout: 5_000,
337
+ });
338
+ if (result.error || result.status !== 0) return null;
339
+ const output = `${result.stderr || ""}${result.stdout || ""}`.trim();
340
+ return output || null;
341
+ }
342
+
343
+ /*
344
+ * PID handling is deliberately self-contained rather than reusing the
345
+ * process.ts helpers: stopProcessByPidFile's isVellumProcess() guard only
346
+ * matches command lines containing a vellum path, which fails for a custom
347
+ * VELLUM_WORKSPACE_DIR and would silently leave nginx running (the same
348
+ * reason local.ts kills ngrok directly). This module is also imported by
349
+ * sleep/retire, whose tests mock.module() process.js process-globally —
350
+ * depending on it here would couple this lib's behavior to those mocks.
351
+ */
352
+
353
+ function readPidFile(pidPath: string): number | null {
354
+ try {
355
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
356
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+
362
+ function isPidAlive(pid: number): boolean {
363
+ try {
364
+ process.kill(pid, 0);
365
+ return true;
366
+ } catch {
367
+ return false;
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Check whether a PID belongs to this ingress nginx process.
373
+ *
374
+ * Matching only the executable name is not enough: a stale pidfile can point
375
+ * at a system nginx or another assistant's ingress after PID reuse.
376
+ */
377
+ function isIngressNginxProcess(pid: number, paths: IngressPaths): boolean {
378
+ try {
379
+ const output = execFileSync(
380
+ "ps",
381
+ ["-ww", "-p", String(pid), "-o", "command="],
382
+ {
383
+ encoding: "utf-8",
384
+ timeout: 3000,
385
+ stdio: ["ignore", "pipe", "ignore"],
386
+ },
387
+ ).trim();
388
+ return (
389
+ /nginx/.test(output) &&
390
+ output.includes(paths.dir) &&
391
+ output.includes(paths.confPath)
392
+ );
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
398
+ /** The ingress nginx PID when it is recorded and alive, null otherwise. */
399
+ export function getIngressPid(workspaceDir: string): number | null {
400
+ const paths = getIngressPaths(workspaceDir);
401
+ const pid = readPidFile(paths.pidPath);
402
+ return pid !== null && isPidAlive(pid) && isIngressNginxProcess(pid, paths)
403
+ ? pid
404
+ : null;
405
+ }
406
+
407
+ export function isIngressRunning(workspaceDir: string): boolean {
408
+ return getIngressPid(workspaceDir) !== null;
409
+ }
410
+
411
+ interface IngressState {
412
+ listenPort: number;
413
+ }
414
+
415
+ function readIngressState(workspaceDir: string): IngressState | null {
416
+ const config = loadRawConfig(workspaceDir);
417
+ const ingress = config.ingress as Record<string, unknown> | undefined;
418
+ const nginx = ingress?.nginx as Record<string, unknown> | undefined;
419
+ const listenPort = nginx?.listenPort;
420
+ if (typeof listenPort !== "number") return null;
421
+ return { listenPort };
422
+ }
423
+
424
+ function saveIngressState(workspaceDir: string, state: IngressState): void {
425
+ const config = loadRawConfig(workspaceDir);
426
+ const ingress = (config.ingress ?? {}) as Record<string, unknown>;
427
+ ingress.nginx = { listenPort: state.listenPort };
428
+ config.ingress = ingress;
429
+ saveRawConfig(workspaceDir, config);
430
+ }
431
+
432
+ function clearIngressState(workspaceDir: string): void {
433
+ const config = loadRawConfig(workspaceDir);
434
+ const ingress = config.ingress as Record<string, unknown> | undefined;
435
+ if (!ingress) return;
436
+ delete ingress.nginx;
437
+ saveRawConfig(workspaceDir, config);
438
+ }
439
+
440
+ function clearStoppedIngress(workspaceDir: string, pidPath: string): void {
441
+ clearIngressState(workspaceDir);
442
+ rmSync(pidPath, { force: true });
443
+ }
444
+
445
+ /**
446
+ * Write the nginx config and spawn nginx detached (same idiom as the ngrok
447
+ * spawn in ngrok.ts: stdout/stderr to a log file, fd closed after spawn,
448
+ * caller unrefs). nginx runs with `daemon off` so the spawned process is the
449
+ * master; it writes its pid to nginx.pid under the prefix dir.
450
+ */
451
+ export function startIngressNginx(opts: {
452
+ workspaceDir: string;
453
+ gatewayPort: number;
454
+ listenPort: number;
455
+ remoteWebIngress?: RemoteWebIngressOptions;
456
+ }): ChildProcess {
457
+ const paths = getIngressPaths(opts.workspaceDir);
458
+ mkdirSync(paths.dir, { recursive: true });
459
+ mkdirSync(join(opts.workspaceDir, "data", "logs"), { recursive: true });
460
+ const remoteWebIngress = opts.remoteWebIngress
461
+ ? {
462
+ ...opts.remoteWebIngress,
463
+ config: remoteWebIngressConfig(opts.remoteWebIngress.config),
464
+ indexHtmlPath: join(paths.dir, "assistant-index.html"),
465
+ }
466
+ : undefined;
467
+ if (remoteWebIngress) {
468
+ const rawIndexHtml = readFileSync(
469
+ join(remoteWebIngress.webDistDir, "index.html"),
470
+ "utf-8",
471
+ );
472
+ writeFileSync(
473
+ remoteWebIngress.indexHtmlPath,
474
+ buildRemoteWebIndexHtml(rawIndexHtml, remoteWebIngress.config),
475
+ );
476
+ }
477
+ writeFileSync(
478
+ paths.confPath,
479
+ buildIngressNginxConfig({
480
+ gatewayPort: opts.gatewayPort,
481
+ listenPort: opts.listenPort,
482
+ remoteWebIngress,
483
+ }),
484
+ );
485
+
486
+ const fd = openSync(paths.logPath, "a");
487
+ const child = spawn(
488
+ nginxBin(),
489
+ ["-p", paths.dir, "-c", paths.confPath, "-g", "daemon off;"],
490
+ { detached: true, stdio: ["ignore", fd, fd] },
491
+ );
492
+ closeSync(fd);
493
+
494
+ saveIngressState(opts.workspaceDir, { listenPort: opts.listenPort });
495
+ return child;
496
+ }
497
+
498
+ const STOP_TIMEOUT_MS = 2_000;
499
+
500
+ async function waitForPidExit(
501
+ pid: number,
502
+ timeoutMs: number,
503
+ ): Promise<boolean> {
504
+ const deadline = Date.now() + timeoutMs;
505
+ while (Date.now() < deadline) {
506
+ if (!isPidAlive(pid)) return true;
507
+ await new Promise((r) => setTimeout(r, 100));
508
+ }
509
+ return !isPidAlive(pid);
510
+ }
511
+
512
+ /**
513
+ * Stop a running ingress nginx via its pidfile and clear the recorded state.
514
+ * Returns true if a process was stopped.
515
+ *
516
+ * Verifies the PID still belongs to this ingress nginx before killing to avoid
517
+ * hitting an unrelated process if the OS has reused the PID. SIGTERM is nginx
518
+ * fast shutdown; escalate to SIGKILL if it doesn't exit within the timeout.
519
+ */
520
+ export async function stopIngressNginx(workspaceDir: string): Promise<boolean> {
521
+ const paths = getIngressPaths(workspaceDir);
522
+
523
+ const pid = readPidFile(paths.pidPath);
524
+ if (pid === null || !isPidAlive(pid) || !isIngressNginxProcess(pid, paths)) {
525
+ clearStoppedIngress(workspaceDir, paths.pidPath);
526
+ return false;
527
+ }
528
+
529
+ try {
530
+ process.kill(pid, "SIGTERM");
531
+ if (!(await waitForPidExit(pid, STOP_TIMEOUT_MS))) {
532
+ try {
533
+ process.kill(pid, "SIGKILL");
534
+ } catch {
535
+ if (!isPidAlive(pid)) {
536
+ clearStoppedIngress(workspaceDir, paths.pidPath);
537
+ return true;
538
+ }
539
+ return false;
540
+ }
541
+ if (!(await waitForPidExit(pid, STOP_TIMEOUT_MS))) {
542
+ return false;
543
+ }
544
+ }
545
+ } catch {
546
+ if (!isPidAlive(pid)) {
547
+ clearStoppedIngress(workspaceDir, paths.pidPath);
548
+ return true;
549
+ }
550
+ return false;
551
+ }
552
+
553
+ clearStoppedIngress(workspaceDir, paths.pidPath);
554
+ return true;
555
+ }
556
+
557
+ /**
558
+ * Resolve the local port a tunnel should target: the nginx ingress when it is
559
+ * recorded AND its process is alive, otherwise the gateway port directly
560
+ * (unchanged behavior when the proxy is not running).
561
+ */
562
+ export function resolveTunnelTargetPort(
563
+ workspaceDir: string,
564
+ gatewayPort: number = GATEWAY_PORT,
565
+ opts: { preferNginxIngress?: boolean } = {},
566
+ ): { port: number; viaIngress: boolean } {
567
+ if (opts.preferNginxIngress === false) {
568
+ return { port: gatewayPort, viaIngress: false };
569
+ }
570
+
571
+ const state = readIngressState(workspaceDir);
572
+ if (state && isIngressRunning(workspaceDir)) {
573
+ return { port: state.listenPort, viaIngress: true };
574
+ }
575
+ return { port: gatewayPort, viaIngress: false };
576
+ }
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(): Promise<void> {
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 port = GATEWAY_PORT;
330
- const workspaceDir = getDefaultWorkspaceDir();
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);
@@ -177,7 +177,6 @@ export interface HatchedAssistant {
177
177
  id: string;
178
178
  name: string;
179
179
  status: string;
180
- current_release_version?: string | null;
181
180
  }
182
181
 
183
182
  export interface HatchAssistantResult {
@@ -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) {