@vellumai/cli 0.10.12-staging.1 → 0.10.12-staging.3

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.
@@ -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
+ });
@@ -4,6 +4,7 @@ import {
4
4
  formatAssistantLookupError,
5
5
  lookupAssistantByIdentifier,
6
6
  } from "../lib/assistant-config.js";
7
+ import { crossEnvironmentAssistantHint } from "../lib/environments/detect.js";
7
8
 
8
9
  type FeatureFlagEntry = {
9
10
  key: string;
@@ -132,7 +133,9 @@ function rethrowFetchError(err: unknown): never {
132
133
  (err.message.includes("fetch") || err.message.includes("connect"))
133
134
  ) {
134
135
  throw new Error(
135
- "Could not reach the assistant gateway. Is it running? Try 'vellum wake'.",
136
+ `Could not reach the assistant gateway. Is it running? Try 'vellum wake'.${
137
+ crossEnvironmentAssistantHint() ?? ""
138
+ }`,
136
139
  );
137
140
  }
138
141
  throw err;
@@ -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> {
@@ -19,6 +19,7 @@ import {
19
19
  buildRemoteWebPairingUrl,
20
20
  normalizePublicBaseUrl,
21
21
  resolvePublicBaseUrl,
22
+ tunnelProviderWebsiteName,
22
23
  type PublicBaseUrlRejection,
23
24
  type RemoteWebPairingChallengeRequest,
24
25
  type RemoteWebPairingChallengeResponse,
@@ -46,6 +47,7 @@ import {
46
47
  } from "../lib/feature-flags.js";
47
48
  import { getLocalLanIPv4 } from "../lib/local.js";
48
49
  import { isLoopbackUrl, loopbackSafeFetch } from "../lib/loopback-fetch.js";
50
+ import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
49
51
 
50
52
  function assistantDisplayName(entry: AssistantEntry): string {
51
53
  return entry.name || entry.assistantName || entry.assistantId;
@@ -277,6 +279,22 @@ export async function pair(): Promise<void> {
277
279
  );
278
280
  args = afterAppScheme;
279
281
 
282
+ // Any `--`-prefixed token left after known-flag extraction is an option this
283
+ // CLI version doesn't support. Fail loud before any network call rather than
284
+ // letting it fall through: an unknown flag would otherwise be silently
285
+ // dropped and the command would run the wrong flow (e.g. `--qr` on an older
286
+ // CLI minting a copy-paste bundle instead of a QR). Positional names never
287
+ // start with `--`, so multi-word assistant targets are unaffected.
288
+ const unknownFlag = args.find((a) => a.startsWith("--"));
289
+ if (unknownFlag) {
290
+ console.error(`Error: unknown option '${unknownFlag}'.`);
291
+ console.error("Run `vellum pair --help` to see available options.");
292
+ console.error(
293
+ `If this option is from newer docs, ${STALE_CLI_UPDATE_HINT}`,
294
+ );
295
+ process.exit(1);
296
+ }
297
+
280
298
  if (webPairing && webApproveCode) {
281
299
  console.error("Error: use either --web or --web-approve, not both.");
282
300
  process.exit(1);
@@ -371,6 +389,10 @@ export async function pair(): Promise<void> {
371
389
  console.error(
372
390
  `Re-run with a reachable URL, e.g.:\n vellum pair --url ${suggestion}`,
373
391
  );
392
+ console.error(
393
+ "Pairing a phone by QR instead? Use: vellum pair --qr " +
394
+ "(needs an https URL — run `vellum tunnel --provider tailscale` first).",
395
+ );
374
396
  process.exit(1);
375
397
  }
376
398
 
@@ -451,6 +473,9 @@ export async function pair(): Promise<void> {
451
473
  unparseable: `${advertisedUrl} isn't a valid URL`,
452
474
  loopback: `${advertisedUrl} is a loopback address`,
453
475
  "non-https": `${advertisedUrl} is not https`,
476
+ "service-website": `${advertisedUrl} is ${
477
+ tunnelProviderWebsiteName(advertisedUrl) ?? "a tunnel provider"
478
+ }'s website, not your assistant's address`,
454
479
  };
455
480
  console.error(
456
481
  "Error: --qr needs a public https URL the phone can open — " +
@@ -8,6 +8,7 @@ import {
8
8
  WEB_REMOTE_INGRESS_FLAG,
9
9
  } from "../lib/feature-flags.js";
10
10
  import { runNgrokTunnel } from "../lib/ngrok";
11
+ import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
11
12
  import { runTailscaleTunnel } from "../lib/tailscale-tunnel.js";
12
13
 
13
14
  const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
@@ -78,12 +79,21 @@ function parseArgs(): TunnelArgs {
78
79
  process.exit(0);
79
80
  } else if (arg === "--provider") {
80
81
  const next = args[i + 1];
81
- if (!next || !VALID_PROVIDERS.includes(next as TunnelProvider)) {
82
+ if (!next || next.startsWith("-")) {
82
83
  console.error(
83
84
  `Error: --provider requires one of: ${VALID_PROVIDERS.join(", ")}`,
84
85
  );
85
86
  process.exit(1);
86
87
  }
88
+ if (!VALID_PROVIDERS.includes(next as TunnelProvider)) {
89
+ console.error(
90
+ `Error: unknown tunnel provider '${next}'. Valid providers: ${VALID_PROVIDERS.join(", ")}.`,
91
+ );
92
+ console.error(
93
+ `If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
94
+ );
95
+ process.exit(1);
96
+ }
87
97
  provider = next as TunnelProvider;
88
98
  i++;
89
99
  } else if (arg.startsWith("-")) {
@@ -199,5 +209,8 @@ export async function tunnel(): Promise<void> {
199
209
  return;
200
210
  }
201
211
 
202
- throw new Error(`Tunnel provider '${provider}' is not yet implemented.`);
212
+ throw new Error(
213
+ `Tunnel provider '${provider}' is not yet implemented. ` +
214
+ `If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
215
+ );
203
216
  }
@@ -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
+ }
@@ -18,6 +18,7 @@ import {
18
18
  } from "@vellumai/local-mode/contract";
19
19
 
20
20
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "./constants.js";
21
+ import { crossEnvironmentAssistantHint } from "./environments/detect.js";
21
22
  import {
22
23
  getDefaultPorts,
23
24
  getLockfilePath,
@@ -423,7 +424,7 @@ export function formatAssistantLookupError(
423
424
  return `Multiple assistants match '${identifier}': ${matches}. Use the assistant ID to disambiguate.`;
424
425
  }
425
426
 
426
- return `No assistant found with name or ID '${identifier}'.`;
427
+ return `No assistant found with name or ID '${identifier}'.${crossEnvironmentAssistantHint() ?? ""}`;
427
428
  }
428
429
 
429
430
  export function removeAssistantEntry(assistantId: string): void {
@@ -580,7 +581,9 @@ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
580
581
  if (all.length === 1) return all[0];
581
582
 
582
583
  if (all.length === 0) {
583
- console.error("No assistant found. Run 'vellum hatch' first.");
584
+ console.error(
585
+ `No assistant found. Run 'vellum hatch' first.${crossEnvironmentAssistantHint() ?? ""}`,
586
+ );
584
587
  } else {
585
588
  console.error(
586
589
  `Multiple assistants found. Set an active assistant with 'vellum use <name>'.`,