@vellumai/cli 0.10.11-dev.202607232226.bf9eaf3 → 0.10.11-dev.202607240043.b26a41c

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.11-dev.202607232226.bf9eaf3",
3
+ "version": "0.10.11-dev.202607240043.b26a41c",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,8 +14,11 @@ import { join } from "node:path";
14
14
 
15
15
  import * as assistantConfig from "../lib/assistant-config.js";
16
16
  import * as docker from "../lib/docker.js";
17
+ import * as featureFlags from "../lib/feature-flags.js";
17
18
  import * as guardianToken from "../lib/guardian-token.js";
19
+ import * as ingressConfig from "../lib/ingress-config.js";
18
20
  import * as local from "../lib/local.js";
21
+ import * as nginxIngress from "../lib/nginx-ingress.js";
19
22
  import * as ngrok from "../lib/ngrok.js";
20
23
  import * as processLib from "../lib/process.js";
21
24
  import type { AssistantEntry } from "../lib/assistant-config.js";
@@ -138,11 +141,50 @@ mock.module("../lib/ngrok", () => ({
138
141
  maybeStartNgrokTunnel: maybeStartNgrokTunnelMock,
139
142
  }));
140
143
 
144
+ const realFeatureFlags = { ...featureFlags };
145
+ const realIngressConfig = { ...ingressConfig };
146
+ const realNginxIngress = { ...nginxIngress };
147
+
148
+ const isAssistantFeatureFlagEnabledMock = mock<
149
+ typeof featureFlags.isAssistantFeatureFlagEnabled
150
+ >(async () => true);
151
+
152
+ mock.module("../lib/feature-flags.js", () => ({
153
+ ...realFeatureFlags,
154
+ isAssistantFeatureFlagEnabled: isAssistantFeatureFlagEnabledMock,
155
+ }));
156
+
157
+ const loadRawConfigMock = mock<typeof ingressConfig.loadRawConfig>(() => ({}));
158
+
159
+ mock.module("../lib/ingress-config.js", () => ({
160
+ ...realIngressConfig,
161
+ loadRawConfig: loadRawConfigMock,
162
+ }));
163
+
164
+ const isIngressRunningMock = mock<typeof nginxIngress.isIngressRunning>(
165
+ () => false,
166
+ );
167
+ const startRemoteWebIngressMock = mock<
168
+ typeof nginxIngress.startRemoteWebIngress
169
+ >(async () => ({
170
+ status: "started",
171
+ listenPort: 7840,
172
+ webDistDir: "/tmp/web/dist",
173
+ version: "nginx/1.25.3",
174
+ }));
175
+
176
+ mock.module("../lib/nginx-ingress.js", () => ({
177
+ ...realNginxIngress,
178
+ isIngressRunning: isIngressRunningMock,
179
+ startRemoteWebIngress: startRemoteWebIngressMock,
180
+ }));
181
+
141
182
  const { wake } = await import("../commands/wake.js");
142
183
 
143
184
  let tempDir: string;
144
185
  let originalArgv: string[];
145
186
  let logSpy: ReturnType<typeof spyOn>;
187
+ let warnSpy: ReturnType<typeof spyOn>;
146
188
 
147
189
  function makeLocalEntry(): AssistantEntry {
148
190
  tempDir = mkdtempSync(join(tmpdir(), "vellum-wake-test-"));
@@ -167,6 +209,7 @@ beforeEach(() => {
167
209
  tempDir = "";
168
210
  process.argv = ["bun", "vellum", "wake", "--watch", "local-assistant"];
169
211
  logSpy = spyOn(console, "log").mockImplementation(() => {});
212
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
170
213
 
171
214
  const entry = makeLocalEntry();
172
215
  resolveTargetAssistantMock.mockReset();
@@ -213,11 +256,25 @@ beforeEach(() => {
213
256
  );
214
257
  maybeStartNgrokTunnelMock.mockReset();
215
258
  maybeStartNgrokTunnelMock.mockResolvedValue(null);
259
+ isAssistantFeatureFlagEnabledMock.mockReset();
260
+ isAssistantFeatureFlagEnabledMock.mockResolvedValue(true);
261
+ loadRawConfigMock.mockReset();
262
+ loadRawConfigMock.mockReturnValue({});
263
+ isIngressRunningMock.mockReset();
264
+ isIngressRunningMock.mockReturnValue(false);
265
+ startRemoteWebIngressMock.mockReset();
266
+ startRemoteWebIngressMock.mockResolvedValue({
267
+ status: "started",
268
+ listenPort: 7840,
269
+ webDistDir: "/tmp/web/dist",
270
+ version: "nginx/1.25.3",
271
+ });
216
272
  });
217
273
 
218
274
  afterEach(() => {
219
275
  process.argv = originalArgv;
220
276
  logSpy.mockRestore();
277
+ warnSpy.mockRestore();
221
278
  if (tempDir) {
222
279
  rmSync(tempDir, { recursive: true, force: true });
223
280
  }
@@ -230,6 +287,9 @@ afterAll(() => {
230
287
  mock.module("../lib/process", () => realProcessLib);
231
288
  mock.module("../lib/local", () => realLocal);
232
289
  mock.module("../lib/ngrok", () => realNgrok);
290
+ mock.module("../lib/feature-flags.js", () => realFeatureFlags);
291
+ mock.module("../lib/ingress-config.js", () => realIngressConfig);
292
+ mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
233
293
  });
234
294
 
235
295
  describe("vellum wake", () => {
@@ -342,3 +402,121 @@ describe("vellum wake", () => {
342
402
  expect(startLocalDaemonMock).not.toHaveBeenCalled();
343
403
  });
344
404
  });
405
+
406
+ describe("vellum wake — web ingress restore", () => {
407
+ const enabledConfig = {
408
+ ingress: { enabled: true, publicBaseUrl: "https://assistant.example.com" },
409
+ };
410
+
411
+ beforeEach(() => {
412
+ process.argv = ["bun", "vellum", "wake", "local-assistant"];
413
+ });
414
+
415
+ test("restores the edge when ingress is enabled, the flag is on, and it is not already running", async () => {
416
+ loadRawConfigMock.mockReturnValue(enabledConfig);
417
+
418
+ await wake();
419
+
420
+ expect(isAssistantFeatureFlagEnabledMock).toHaveBeenCalledWith(
421
+ "local-assistant",
422
+ "web-remote-ingress",
423
+ { runtimeUrl: "http://127.0.0.1:7830" },
424
+ );
425
+ expect(startRemoteWebIngressMock).toHaveBeenCalledWith(
426
+ expect.objectContaining({
427
+ workspaceDir: join(tempDir, ".vellum", "workspace"),
428
+ gatewayPort: 7830,
429
+ }),
430
+ );
431
+ expect(logSpy).toHaveBeenCalledWith("Wake complete.");
432
+ });
433
+
434
+ test("does not attempt a restore when ingress is disabled", async () => {
435
+ loadRawConfigMock.mockReturnValue({ ingress: { enabled: false } });
436
+
437
+ await wake();
438
+
439
+ expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
440
+ expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
441
+ });
442
+
443
+ test("does not attempt a restore when ingress config is absent", async () => {
444
+ loadRawConfigMock.mockReturnValue({});
445
+
446
+ await wake();
447
+
448
+ expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
449
+ expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
450
+ });
451
+
452
+ test("does not attempt a restore when enabled but the public URL is missing", async () => {
453
+ loadRawConfigMock.mockReturnValue({ ingress: { enabled: true } });
454
+
455
+ await wake();
456
+
457
+ expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
458
+ expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
459
+ });
460
+
461
+ test("does not start a second edge when one is already running", async () => {
462
+ loadRawConfigMock.mockReturnValue(enabledConfig);
463
+ isIngressRunningMock.mockReturnValue(true);
464
+
465
+ await wake();
466
+
467
+ // Already up — skip the flag probe and the start entirely.
468
+ expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
469
+ expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
470
+ });
471
+
472
+ test("skips the restore quietly with a hint when the flag is off", async () => {
473
+ loadRawConfigMock.mockReturnValue(enabledConfig);
474
+ isAssistantFeatureFlagEnabledMock.mockResolvedValue(false);
475
+
476
+ await wake();
477
+
478
+ expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
479
+ expect(logSpy).toHaveBeenCalledWith(
480
+ expect.stringContaining("web-remote-ingress"),
481
+ );
482
+ expect(logSpy).toHaveBeenCalledWith("Wake complete.");
483
+ });
484
+
485
+ test("a thrown restore failure warns but does not fail the wake", async () => {
486
+ loadRawConfigMock.mockReturnValue(enabledConfig);
487
+ startRemoteWebIngressMock.mockRejectedValue(new Error("nginx boom"));
488
+
489
+ await wake();
490
+
491
+ expect(startRemoteWebIngressMock).toHaveBeenCalled();
492
+ expect(warnSpy).toHaveBeenCalled();
493
+ expect(logSpy).toHaveBeenCalledWith("Wake complete.");
494
+ });
495
+
496
+ test("an unreachable edge warns but does not fail the wake", async () => {
497
+ loadRawConfigMock.mockReturnValue(enabledConfig);
498
+ startRemoteWebIngressMock.mockResolvedValue({
499
+ status: "unreachable",
500
+ listenPort: 7840,
501
+ logPath: "/tmp/nginx-ingress.log",
502
+ });
503
+
504
+ await wake();
505
+
506
+ expect(warnSpy).toHaveBeenCalled();
507
+ expect(logSpy).toHaveBeenCalledWith("Wake complete.");
508
+ });
509
+
510
+ test("a failed flag probe warns and leaves the edge down without failing the wake", async () => {
511
+ loadRawConfigMock.mockReturnValue(enabledConfig);
512
+ isAssistantFeatureFlagEnabledMock.mockRejectedValue(
513
+ new Error("gateway unreachable"),
514
+ );
515
+
516
+ await wake();
517
+
518
+ expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
519
+ expect(warnSpy).toHaveBeenCalled();
520
+ expect(logSpy).toHaveBeenCalledWith("Wake complete.");
521
+ });
522
+ });
@@ -14,22 +14,16 @@ import {
14
14
  isAssistantFeatureFlagEnabled,
15
15
  WEB_REMOTE_INGRESS_FLAG,
16
16
  } from "../lib/feature-flags.js";
17
- import { waitForDaemonReady } from "../lib/http-client.js";
18
17
  import {
19
18
  DEFAULT_NGINX_INGRESS_PORT,
20
- findWebDistDir,
21
19
  getIngressPaths,
22
20
  getIngressPid,
23
- getNginxIngressPort,
24
- getNginxVersion,
25
21
  isIngressRunning,
26
22
  resolveTunnelTargetPort,
27
- startIngressNginx,
23
+ startRemoteWebIngress,
28
24
  stopIngressNginx,
29
25
  } from "../lib/nginx-ingress.js";
30
26
 
31
- const READY_TIMEOUT_MS = 5_000;
32
-
33
27
  function printHelp(): void {
34
28
  console.log("Usage: vellum nginx-ingress <subcommand> [<name>] [options]");
35
29
  console.log("");
@@ -164,79 +158,71 @@ async function assertWebRemoteIngressEnabled(
164
158
 
165
159
  async function up(target: NginxIngressTarget): Promise<void> {
166
160
  const { workspaceDir, gatewayPort } = target;
167
- const listenPort = getNginxIngressPort();
168
161
 
169
162
  await assertWebRemoteIngressEnabled(target);
170
163
 
171
- const version = getNginxVersion();
172
- if (!version) {
173
- console.error("Error: nginx is not installed.");
174
- console.error("");
175
- console.error("Install nginx:");
176
- console.error(" macOS: brew install nginx");
177
- console.error(" Linux: sudo apt install nginx");
178
- console.error("");
179
- console.error(
180
- "Or point NGINX_BIN at an existing binary: NGINX_BIN=/path/to/nginx",
181
- );
182
- process.exit(1);
183
- }
184
-
185
- if (isIngressRunning(workspaceDir)) {
186
- console.log("nginx ingress is already running.");
187
- await status(target);
188
- return;
189
- }
190
-
191
- const webDistDir = findWebDistDir();
192
- if (!webDistDir) {
193
- console.error(
194
- "Error: unable to locate built web assets for remote web ingress.",
195
- );
196
- console.error("");
197
- console.error("Build the SPA first:");
198
- console.error(" cd clients/web && VITE_PLATFORM_MODE=false bun run build");
199
- console.error("");
200
- console.error(
201
- "Or install @vellumai/web so its packaged dist directory is available.",
202
- );
203
- process.exit(1);
204
- }
205
-
206
- console.log(`Using ${version}`);
207
- console.log(
208
- `Starting nginx ingress on 127.0.0.1:${listenPort} → web ${webDistDir} + gateway 127.0.0.1:${gatewayPort}...`,
209
- );
210
-
211
- const child = startIngressNginx({
164
+ const result = await startRemoteWebIngress({
212
165
  workspaceDir,
213
166
  gatewayPort,
214
- listenPort,
215
- remoteWebIngress: { webDistDir },
167
+ onStarting: ({ version, webDistDir, listenPort }) => {
168
+ console.log(`Using ${version}`);
169
+ console.log(
170
+ `Starting nginx ingress on 127.0.0.1:${listenPort} → web ${webDistDir} + gateway 127.0.0.1:${gatewayPort}...`,
171
+ );
172
+ },
216
173
  });
217
- child.unref();
218
174
 
219
- // /healthz proxies through nginx to the gateway, so a 200 proves the whole
220
- // ingress → gateway path works.
221
- const ready = await waitForDaemonReady(listenPort, READY_TIMEOUT_MS);
222
- if (!ready) {
223
- const { logPath } = getIngressPaths(workspaceDir);
224
- await stopIngressNginx(workspaceDir);
225
- console.error(
226
- `Error: nginx ingress did not become reachable on 127.0.0.1:${listenPort}.`,
227
- );
228
- console.error(`Check the nginx log: ${logPath}`);
229
- process.exit(1);
175
+ switch (result.status) {
176
+ case "already-running":
177
+ console.log("nginx ingress is already running.");
178
+ await status(target);
179
+ return;
180
+ case "started":
181
+ console.log("");
182
+ console.log(
183
+ `nginx ingress running: http://127.0.0.1:${result.listenPort}`,
184
+ );
185
+ console.log("");
186
+ console.log("Next steps:");
187
+ console.log(
188
+ " vellum tunnel --provider ngrok # tunnel now targets nginx ingress",
189
+ );
190
+ console.log(" vellum nginx-ingress down # stop the proxy");
191
+ return;
192
+ case "nginx-missing":
193
+ console.error("Error: nginx is not installed.");
194
+ console.error("");
195
+ console.error("Install nginx:");
196
+ console.error(" macOS: brew install nginx");
197
+ console.error(" Linux: sudo apt install nginx");
198
+ console.error("");
199
+ console.error(
200
+ "Or point NGINX_BIN at an existing binary: NGINX_BIN=/path/to/nginx",
201
+ );
202
+ break;
203
+ case "web-dist-missing":
204
+ console.error(
205
+ "Error: unable to locate built web assets for remote web ingress.",
206
+ );
207
+ console.error("");
208
+ console.error("Build the SPA first:");
209
+ console.error(
210
+ " cd clients/web && VITE_PLATFORM_MODE=false bun run build",
211
+ );
212
+ console.error("");
213
+ console.error(
214
+ "Or install @vellumai/web so its packaged dist directory is available.",
215
+ );
216
+ break;
217
+ case "unreachable":
218
+ console.error(
219
+ `Error: nginx ingress did not become reachable on 127.0.0.1:${result.listenPort}.`,
220
+ );
221
+ console.error(`Check the nginx log: ${result.logPath}`);
222
+ break;
230
223
  }
231
224
 
232
- console.log("");
233
- console.log(`nginx ingress running: http://127.0.0.1:${listenPort}`);
234
- console.log("");
235
- console.log("Next steps:");
236
- console.log(
237
- " vellum tunnel --provider ngrok # tunnel now targets nginx ingress",
238
- );
239
- console.log(" vellum nginx-ingress down # stop the proxy");
225
+ process.exit(1);
240
226
  }
241
227
 
242
228
  async function down(target: NginxIngressTarget): Promise<void> {
@@ -30,6 +30,15 @@ import {
30
30
  startGateway,
31
31
  } from "../lib/local";
32
32
  import { maybeStartNgrokTunnel } from "../lib/ngrok";
33
+ import {
34
+ isAssistantFeatureFlagEnabled,
35
+ WEB_REMOTE_INGRESS_FLAG,
36
+ } from "../lib/feature-flags.js";
37
+ import { loadRawConfig } from "../lib/ingress-config.js";
38
+ import {
39
+ isIngressRunning,
40
+ startRemoteWebIngress,
41
+ } from "../lib/nginx-ingress.js";
33
42
 
34
43
  export async function wake(): Promise<void> {
35
44
  const args = process.argv.slice(3);
@@ -379,6 +388,17 @@ export async function wake(): Promise<void> {
379
388
  writeFileSync(ngrokPidFile, String(ngrokChild.pid));
380
389
  }
381
390
 
391
+ // Restore the nginx web ingress edge when the workspace config still wants
392
+ // it. A TLS-terminating front (tailscale serve / tunnel) persists across
393
+ // restarts and keeps proxying to the edge's loopback port, but the edge has
394
+ // a manual lifecycle — so a routine restart otherwise leaves the self-hosted
395
+ // remote-web path dead (502 / blank page) until someone runs it back up.
396
+ await restoreWebIngressIfEnabled(
397
+ entry.assistantId,
398
+ resources.gatewayPort,
399
+ workspaceDir,
400
+ );
401
+
382
402
  if (daemonMigrationsFailed) {
383
403
  console.log(
384
404
  "Assistant database migrations FAILED — DB-backed routes will return 503 until the assistant is restarted. Check the daemon logs.",
@@ -404,3 +424,101 @@ export async function wake(): Promise<void> {
404
424
  });
405
425
  }
406
426
  }
427
+
428
+ /**
429
+ * Bring the nginx web ingress edge back up after a wake when the workspace
430
+ * config still wants it. Only restores when ingress is explicitly enabled with
431
+ * a saved public URL and the `web-remote-ingress` flag is on — the edge is
432
+ * pointless without the flag, so a disabled flag skips quietly with a hint.
433
+ *
434
+ * Reads the same workspace config the edge serves. Any failure to restore
435
+ * warns with the manual `vellum nginx-ingress up` command and never fails the
436
+ * wake — a down edge is a degraded remote-web path, not a broken assistant.
437
+ */
438
+ async function restoreWebIngressIfEnabled(
439
+ assistantId: string,
440
+ gatewayPort: number,
441
+ workspaceDir: string,
442
+ ): Promise<void> {
443
+ const config = loadRawConfig(workspaceDir);
444
+ const ingress = config.ingress as
445
+ | { enabled?: unknown; publicBaseUrl?: unknown }
446
+ | undefined;
447
+ const enabled = ingress?.enabled === true;
448
+ const publicBaseUrl =
449
+ typeof ingress?.publicBaseUrl === "string"
450
+ ? ingress.publicBaseUrl.trim()
451
+ : "";
452
+ if (!enabled || !publicBaseUrl) {
453
+ return;
454
+ }
455
+
456
+ // The edge already survived (or was manually brought back) — nothing to do.
457
+ if (isIngressRunning(workspaceDir)) {
458
+ return;
459
+ }
460
+
461
+ let flagEnabled: boolean;
462
+ try {
463
+ flagEnabled = await isAssistantFeatureFlagEnabled(
464
+ assistantId,
465
+ WEB_REMOTE_INGRESS_FLAG,
466
+ { runtimeUrl: `http://127.0.0.1:${gatewayPort}` },
467
+ );
468
+ } catch (err) {
469
+ console.warn(
470
+ ` Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` flag to restore the web ingress edge; leaving it down. Bring it up manually with \`vellum nginx-ingress up\`. ${
471
+ err instanceof Error ? err.message : String(err)
472
+ }`,
473
+ );
474
+ return;
475
+ }
476
+ if (!flagEnabled) {
477
+ console.log(
478
+ ` Web ingress edge not restored: the \`${WEB_REMOTE_INGRESS_FLAG}\` flag is off. Enable it and run \`vellum nginx-ingress up\` to serve remote web access.`,
479
+ );
480
+ return;
481
+ }
482
+
483
+ try {
484
+ const result = await startRemoteWebIngress({
485
+ workspaceDir,
486
+ gatewayPort,
487
+ onStarting: ({ listenPort }) => {
488
+ console.log(
489
+ `Restoring web ingress edge on 127.0.0.1:${listenPort} (ingress.enabled)...`,
490
+ );
491
+ },
492
+ });
493
+ switch (result.status) {
494
+ case "started":
495
+ console.log(
496
+ ` Web ingress edge running: http://127.0.0.1:${result.listenPort}`,
497
+ );
498
+ break;
499
+ case "already-running":
500
+ break;
501
+ case "nginx-missing":
502
+ console.warn(
503
+ " Could not restore the web ingress edge: nginx is not installed. Bring it up manually with `vellum nginx-ingress up`.",
504
+ );
505
+ break;
506
+ case "web-dist-missing":
507
+ console.warn(
508
+ " Could not restore the web ingress edge: built web assets were not found. Bring it up manually with `vellum nginx-ingress up`.",
509
+ );
510
+ break;
511
+ case "unreachable":
512
+ console.warn(
513
+ ` Web ingress edge did not become reachable on 127.0.0.1:${result.listenPort}; check ${result.logPath}. Bring it up manually with \`vellum nginx-ingress up\`.`,
514
+ );
515
+ break;
516
+ }
517
+ } catch (err) {
518
+ console.warn(
519
+ ` Failed to restore the web ingress edge: ${
520
+ err instanceof Error ? err.message : String(err)
521
+ }. Bring it up manually with \`vellum nginx-ingress up\`.`,
522
+ );
523
+ }
524
+ }
@@ -17,6 +17,7 @@ import { createRequire } from "node:module";
17
17
  import { dirname, join } from "node:path";
18
18
 
19
19
  import { GATEWAY_PORT } from "./constants.js";
20
+ import { waitForDaemonReady } from "./http-client.js";
20
21
  import { loadRawConfig, saveRawConfig } from "./ingress-config.js";
21
22
 
22
23
  /**
@@ -540,6 +541,95 @@ export async function stopIngressNginx(workspaceDir: string): Promise<boolean> {
540
541
  return true;
541
542
  }
542
543
 
544
+ /** Probe budget for confirming the freshly-spawned edge answers /healthz. */
545
+ export const INGRESS_READY_TIMEOUT_MS = 5_000;
546
+
547
+ /**
548
+ * Outcome of an attempt to bring up the remote-web nginx ingress edge. Callers
549
+ * render their own messaging per variant: `nginx-ingress up` prints
550
+ * install/build guidance and exits non-zero, while the wake restore path warns
551
+ * and continues.
552
+ */
553
+ export type StartRemoteWebIngressResult =
554
+ | {
555
+ status: "started";
556
+ listenPort: number;
557
+ webDistDir: string;
558
+ version: string;
559
+ }
560
+ | { status: "already-running"; listenPort: number }
561
+ | { status: "nginx-missing" }
562
+ | { status: "web-dist-missing" }
563
+ | { status: "unreachable"; listenPort: number; logPath: string };
564
+
565
+ /**
566
+ * Generate the nginx config and start the remote-web ingress edge, then probe
567
+ * /healthz through it to prove the ingress → gateway path is live. A spawned
568
+ * but unreachable nginx is rolled back so a failed attempt leaves no half-up
569
+ * edge behind.
570
+ *
571
+ * Pure mechanism: it performs no console output and never exits the process, so
572
+ * both the `nginx-ingress up` command and the wake restore path can share one
573
+ * implementation and map the returned result to their own UX.
574
+ */
575
+ export async function startRemoteWebIngress(opts: {
576
+ workspaceDir: string;
577
+ gatewayPort: number;
578
+ listenPort?: number;
579
+ readyTimeoutMs?: number;
580
+ /**
581
+ * Invoked once, after every preflight check passes and immediately before
582
+ * nginx is spawned, so callers can emit their own "starting" progress line
583
+ * with the resolved version/dist/port. Never fires on a preflight bail-out
584
+ * (nginx-missing, already-running, web-dist-missing).
585
+ */
586
+ onStarting?: (info: {
587
+ version: string;
588
+ webDistDir: string;
589
+ listenPort: number;
590
+ }) => void;
591
+ }): Promise<StartRemoteWebIngressResult> {
592
+ const listenPort = opts.listenPort ?? getNginxIngressPort();
593
+
594
+ const version = getNginxVersion();
595
+ if (!version) {
596
+ return { status: "nginx-missing" };
597
+ }
598
+
599
+ if (isIngressRunning(opts.workspaceDir)) {
600
+ return { status: "already-running", listenPort };
601
+ }
602
+
603
+ const webDistDir = findWebDistDir();
604
+ if (!webDistDir) {
605
+ return { status: "web-dist-missing" };
606
+ }
607
+
608
+ opts.onStarting?.({ version, webDistDir, listenPort });
609
+
610
+ const child = startIngressNginx({
611
+ workspaceDir: opts.workspaceDir,
612
+ gatewayPort: opts.gatewayPort,
613
+ listenPort,
614
+ remoteWebIngress: { webDistDir },
615
+ });
616
+ child.unref();
617
+
618
+ // /healthz proxies through nginx to the gateway, so a 200 proves the whole
619
+ // ingress → gateway path works.
620
+ const ready = await waitForDaemonReady(
621
+ listenPort,
622
+ opts.readyTimeoutMs ?? INGRESS_READY_TIMEOUT_MS,
623
+ );
624
+ if (!ready) {
625
+ const { logPath } = getIngressPaths(opts.workspaceDir);
626
+ await stopIngressNginx(opts.workspaceDir);
627
+ return { status: "unreachable", listenPort, logPath };
628
+ }
629
+
630
+ return { status: "started", listenPort, webDistDir, version };
631
+ }
632
+
543
633
  /**
544
634
  * Resolve the local port a tunnel should target: the nginx ingress when it is
545
635
  * recorded AND its process is alive, otherwise the gateway port directly