@vellumai/cli 0.10.12-staging.1 → 0.10.12-staging.2
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/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +25 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +7 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +24 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +69 -1
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +118 -2
- package/src/__tests__/flags.test.ts +77 -0
- package/src/__tests__/pair.test.ts +146 -0
- package/src/__tests__/tunnel.test.ts +53 -0
- package/src/__tests__/wake.test.ts +178 -0
- package/src/commands/flags.ts +4 -1
- package/src/commands/nginx-ingress.ts +57 -71
- package/src/commands/pair.ts +25 -0
- package/src/commands/tunnel.ts +15 -2
- package/src/commands/wake.ts +118 -0
- package/src/lib/assistant-config.ts +5 -2
- package/src/lib/environments/__tests__/detect.test.ts +101 -0
- package/src/lib/environments/detect.ts +79 -0
- package/src/lib/nginx-ingress.ts +90 -0
- package/src/lib/stale-cli-hint.ts +11 -0
|
@@ -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
|
+
});
|
package/src/commands/flags.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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> {
|
package/src/commands/pair.ts
CHANGED
|
@@ -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 — " +
|
package/src/commands/tunnel.ts
CHANGED
|
@@ -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 ||
|
|
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(
|
|
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
|
}
|
package/src/commands/wake.ts
CHANGED
|
@@ -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(
|
|
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>'.`,
|