@vellumai/cli 0.8.12-staging.2 → 0.9.0-staging.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bun.lock +49 -56
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
- package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
- package/package.json +3 -3
- package/src/__tests__/assistant-config.test.ts +1 -2
- package/src/__tests__/device-id.test.ts +6 -14
- package/src/__tests__/helpers/os-mock.ts +27 -0
- package/src/__tests__/login-loopback.test.ts +71 -0
- package/src/__tests__/multi-local.test.ts +2 -10
- package/src/__tests__/nginx-ingress-command.test.ts +69 -0
- package/src/__tests__/nginx-ingress.test.ts +401 -0
- package/src/__tests__/sleep.test.ts +4 -0
- package/src/__tests__/teleport.test.ts +6 -9
- package/src/__tests__/tunnel.test.ts +164 -0
- package/src/__tests__/wake.test.ts +15 -4
- package/src/__tests__/workos-pkce.test.ts +314 -0
- package/src/commands/flags.ts +1 -22
- package/src/commands/hatch.ts +90 -9
- package/src/commands/login.ts +123 -59
- package/src/commands/nginx-ingress.ts +291 -0
- package/src/commands/rollback.ts +0 -6
- package/src/commands/sleep.ts +17 -0
- package/src/commands/teleport.ts +23 -36
- package/src/commands/tunnel.ts +69 -11
- package/src/commands/upgrade.ts +0 -2
- package/src/commands/wake.ts +7 -5
- package/src/commands/workflows.ts +301 -0
- package/src/index.ts +8 -0
- package/src/lib/arg-utils.ts +48 -0
- package/src/lib/assistant-client.ts +2 -0
- package/src/lib/assistant-config.ts +0 -7
- package/src/lib/cloudflare-tunnel.ts +15 -2
- package/src/lib/docker.ts +103 -49
- package/src/lib/feature-flags.test.ts +157 -0
- package/src/lib/feature-flags.ts +38 -0
- package/src/lib/hatch-local.ts +0 -1
- package/src/lib/local.ts +5 -0
- package/src/lib/nginx-ingress.ts +574 -0
- package/src/lib/ngrok.ts +26 -4
- package/src/lib/platform-client.ts +0 -1
- package/src/lib/retire-local.ts +5 -0
- package/src/lib/statefulset.ts +73 -21
- package/src/lib/sync-cloud-assistants.ts +4 -17
- package/src/lib/upgrade-lifecycle.ts +1 -2
- package/src/lib/workos-pkce.ts +160 -0
package/src/commands/login.ts
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
|
-
import { createServer } from "http";
|
|
2
1
|
import { spawn } from "child_process";
|
|
3
2
|
import { randomBytes } from "crypto";
|
|
3
|
+
import { createServer } from "http";
|
|
4
|
+
import type { AddressInfo } from "net";
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
getActiveAssistant,
|
|
7
|
-
resolveAssistant,
|
|
8
8
|
loadAllAssistants,
|
|
9
9
|
removeAssistantEntry,
|
|
10
|
+
resolveAssistant,
|
|
10
11
|
setActiveAssistant,
|
|
11
12
|
} from "../lib/assistant-config";
|
|
12
13
|
import { computeDeviceId } from "../lib/guardian-token";
|
|
13
|
-
import {
|
|
14
|
-
fetchAssistantIngressUrl,
|
|
15
|
-
fetchCurrentVersion,
|
|
16
|
-
} from "../lib/upgrade-lifecycle.js";
|
|
17
14
|
import {
|
|
18
15
|
clearPlatformToken,
|
|
19
16
|
ensureSelfHostedLocalRegistration,
|
|
@@ -21,7 +18,6 @@ import {
|
|
|
21
18
|
fetchOrganizationId,
|
|
22
19
|
fetchPlatformAssistants,
|
|
23
20
|
getPlatformUrl,
|
|
24
|
-
getWebUrl,
|
|
25
21
|
injectCredentialsIntoAssistant,
|
|
26
22
|
readGatewayCredential,
|
|
27
23
|
readPlatformToken,
|
|
@@ -29,6 +25,18 @@ import {
|
|
|
29
25
|
savePlatformToken,
|
|
30
26
|
} from "../lib/platform-client";
|
|
31
27
|
import { syncCloudAssistants } from "../lib/sync-cloud-assistants";
|
|
28
|
+
import {
|
|
29
|
+
fetchAssistantIngressUrl,
|
|
30
|
+
fetchCurrentVersion,
|
|
31
|
+
} from "../lib/upgrade-lifecycle.js";
|
|
32
|
+
import {
|
|
33
|
+
CALLBACK_PATH,
|
|
34
|
+
buildAuthorizeUrl,
|
|
35
|
+
exchangeAccessTokenForSession,
|
|
36
|
+
exchangeCodeWithWorkos,
|
|
37
|
+
fetchWorkosClientId,
|
|
38
|
+
generatePkcePair,
|
|
39
|
+
} from "../lib/workos-pkce";
|
|
32
40
|
|
|
33
41
|
const LOGIN_TIMEOUT_MS = 120_000; // 2 minutes
|
|
34
42
|
|
|
@@ -41,7 +49,11 @@ function escapeHtml(s: string): string {
|
|
|
41
49
|
.replace(/'/g, "'");
|
|
42
50
|
}
|
|
43
51
|
|
|
44
|
-
function renderLoginPage(
|
|
52
|
+
function renderLoginPage(
|
|
53
|
+
title: string,
|
|
54
|
+
subtitle: string,
|
|
55
|
+
success: boolean,
|
|
56
|
+
): string {
|
|
45
57
|
const checkmarkSvg = `<svg class="icon" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
46
58
|
<circle cx="28" cy="28" r="28" fill="var(--positive-bg)"/>
|
|
47
59
|
<path class="check" d="M17 28.5L24.5 36L39 21" stroke="var(--positive-fg)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
@@ -175,78 +187,131 @@ function openBrowser(url: string): void {
|
|
|
175
187
|
child.unref();
|
|
176
188
|
}
|
|
177
189
|
|
|
190
|
+
export interface LoopbackListener {
|
|
191
|
+
/** The full `http://127.0.0.1:<port>/auth/callback` redirect URI. */
|
|
192
|
+
redirectUri: string;
|
|
193
|
+
/** Resolves with the authorization code once the state-matched callback arrives. */
|
|
194
|
+
waitForCode: Promise<string>;
|
|
195
|
+
/** Tear down the server, rejecting any pending waiter with `reason`. */
|
|
196
|
+
close: (reason?: string) => void;
|
|
197
|
+
}
|
|
198
|
+
|
|
178
199
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
200
|
+
* Bind an ephemeral 127.0.0.1 listener and wait for the OAuth redirect.
|
|
201
|
+
* Exported for tests; production callers go through `workosPkceLogin`.
|
|
181
202
|
*/
|
|
182
|
-
function
|
|
183
|
-
|
|
184
|
-
|
|
203
|
+
export function startLoopbackListener(
|
|
204
|
+
expectedState: string,
|
|
205
|
+
): Promise<LoopbackListener> {
|
|
206
|
+
return new Promise((resolveListener, rejectListener) => {
|
|
207
|
+
let settle: {
|
|
208
|
+
resolve: (code: string) => void;
|
|
209
|
+
reject: (err: Error) => void;
|
|
210
|
+
};
|
|
211
|
+
const waitForCode = new Promise<string>((resolve, reject) => {
|
|
212
|
+
settle = { resolve, reject };
|
|
213
|
+
});
|
|
185
214
|
|
|
186
215
|
const server = createServer((req, res) => {
|
|
187
|
-
const url = new URL(req.url ?? "/",
|
|
188
|
-
|
|
189
|
-
|
|
216
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
217
|
+
if (
|
|
218
|
+
url.pathname !== CALLBACK_PATH ||
|
|
219
|
+
url.searchParams.get("state") !== expectedState
|
|
220
|
+
) {
|
|
190
221
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
191
222
|
res.end("Not found");
|
|
192
223
|
return;
|
|
193
224
|
}
|
|
194
225
|
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
if (receivedState !== state) {
|
|
226
|
+
const error = url.searchParams.get("error");
|
|
227
|
+
const code = url.searchParams.get("code");
|
|
228
|
+
if (error || !code) {
|
|
199
229
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
200
|
-
res.end(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
230
|
+
res.end(
|
|
231
|
+
renderLoginPage(
|
|
232
|
+
"Login Failed",
|
|
233
|
+
"Please try again from your terminal.",
|
|
234
|
+
false,
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
server.close();
|
|
238
|
+
settle.reject(
|
|
239
|
+
new Error(
|
|
240
|
+
`Authentication failed: ${error ?? "no authorization code received"}`,
|
|
241
|
+
),
|
|
242
|
+
);
|
|
209
243
|
return;
|
|
210
244
|
}
|
|
211
245
|
|
|
212
246
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
213
|
-
res.end(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
function cleanup(error: string | null, token?: string): void {
|
|
222
|
-
clearTimeout(timeout);
|
|
247
|
+
res.end(
|
|
248
|
+
renderLoginPage(
|
|
249
|
+
"Login Successful",
|
|
250
|
+
"You can close this window and return to your terminal.",
|
|
251
|
+
true,
|
|
252
|
+
),
|
|
253
|
+
);
|
|
223
254
|
server.close();
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
} else if (token) {
|
|
227
|
-
resolve(token);
|
|
228
|
-
} else {
|
|
229
|
-
reject(new Error("Unknown error during login."));
|
|
230
|
-
}
|
|
231
|
-
}
|
|
255
|
+
settle.resolve(code);
|
|
256
|
+
});
|
|
232
257
|
|
|
233
|
-
server.on("error",
|
|
258
|
+
server.on("error", rejectListener);
|
|
234
259
|
server.listen(0, "127.0.0.1", () => {
|
|
235
260
|
const addr = server.address();
|
|
236
261
|
if (!addr || typeof addr === "string") {
|
|
237
|
-
|
|
262
|
+
rejectListener(new Error("Failed to start local server."));
|
|
238
263
|
return;
|
|
239
264
|
}
|
|
265
|
+
const { port } = addr as AddressInfo;
|
|
266
|
+
resolveListener({
|
|
267
|
+
redirectUri: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
|
|
268
|
+
waitForCode,
|
|
269
|
+
close: (reason?: string) => {
|
|
270
|
+
server.close();
|
|
271
|
+
settle.reject(new Error(reason ?? "Login cancelled."));
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** App-held WorkOS PKCE login */
|
|
279
|
+
async function workosPkceLogin(platformUrl: string): Promise<string> {
|
|
280
|
+
const clientId = await fetchWorkosClientId(platformUrl);
|
|
281
|
+
const { verifier, challenge } = generatePkcePair();
|
|
282
|
+
const state = randomBytes(32).toString("hex");
|
|
240
283
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
284
|
+
const listener = await startLoopbackListener(state);
|
|
285
|
+
const timeout = setTimeout(() => {
|
|
286
|
+
listener.close("Login timed out. Please try again.");
|
|
287
|
+
}, LOGIN_TIMEOUT_MS);
|
|
244
288
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
289
|
+
try {
|
|
290
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
291
|
+
clientId,
|
|
292
|
+
redirectUri: listener.redirectUri,
|
|
293
|
+
challenge,
|
|
294
|
+
state,
|
|
248
295
|
});
|
|
249
|
-
|
|
296
|
+
|
|
297
|
+
console.log("Opening browser for login...");
|
|
298
|
+
console.log(`If the browser doesn't open, visit: ${authorizeUrl}`);
|
|
299
|
+
openBrowser(authorizeUrl);
|
|
300
|
+
|
|
301
|
+
const code = await listener.waitForCode;
|
|
302
|
+
const accessToken = await exchangeCodeWithWorkos({
|
|
303
|
+
clientId,
|
|
304
|
+
code,
|
|
305
|
+
verifier,
|
|
306
|
+
});
|
|
307
|
+
return await exchangeAccessTokenForSession(
|
|
308
|
+
platformUrl,
|
|
309
|
+
clientId,
|
|
310
|
+
accessToken,
|
|
311
|
+
);
|
|
312
|
+
} finally {
|
|
313
|
+
clearTimeout(timeout);
|
|
314
|
+
}
|
|
250
315
|
}
|
|
251
316
|
|
|
252
317
|
export async function login(): Promise<void> {
|
|
@@ -306,11 +371,10 @@ export async function login(): Promise<void> {
|
|
|
306
371
|
}
|
|
307
372
|
}
|
|
308
373
|
|
|
309
|
-
// If no --token flag, use
|
|
374
|
+
// If no --token flag, use app-held WorkOS PKCE login.
|
|
310
375
|
if (!token) {
|
|
311
|
-
const webUrl = getWebUrl();
|
|
312
376
|
try {
|
|
313
|
-
token = await
|
|
377
|
+
token = await workosPkceLogin(getPlatformUrl());
|
|
314
378
|
} catch (error) {
|
|
315
379
|
console.error(`❌ ${error instanceof Error ? error.message : error}`);
|
|
316
380
|
process.exit(1);
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
formatAssistantLookupError,
|
|
6
|
+
lookupAssistantByIdentifier,
|
|
7
|
+
resolveAssistant,
|
|
8
|
+
} from "../lib/assistant-config.js";
|
|
9
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
10
|
+
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
11
|
+
import { GATEWAY_PORT } from "../lib/constants.js";
|
|
12
|
+
import {
|
|
13
|
+
formatFeatureFlagGateMessage,
|
|
14
|
+
isAssistantFeatureFlagEnabled,
|
|
15
|
+
WEB_REMOTE_INGRESS_FLAG,
|
|
16
|
+
} from "../lib/feature-flags.js";
|
|
17
|
+
import { waitForDaemonReady } from "../lib/http-client.js";
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_NGINX_INGRESS_PORT,
|
|
20
|
+
findWebDistDir,
|
|
21
|
+
getIngressPaths,
|
|
22
|
+
getIngressPid,
|
|
23
|
+
getNginxIngressPort,
|
|
24
|
+
getNginxVersion,
|
|
25
|
+
isIngressRunning,
|
|
26
|
+
resolveTunnelTargetPort,
|
|
27
|
+
startIngressNginx,
|
|
28
|
+
stopIngressNginx,
|
|
29
|
+
} from "../lib/nginx-ingress.js";
|
|
30
|
+
|
|
31
|
+
const READY_TIMEOUT_MS = 5_000;
|
|
32
|
+
|
|
33
|
+
function printHelp(): void {
|
|
34
|
+
console.log("Usage: vellum nginx-ingress <subcommand> [<name>] [options]");
|
|
35
|
+
console.log("");
|
|
36
|
+
console.log(
|
|
37
|
+
"Manage the nginx web edge that serves the SPA and fronts the gateway",
|
|
38
|
+
);
|
|
39
|
+
console.log(
|
|
40
|
+
"for remote web access: browser → tunnel (TLS) → nginx@127.0.0.1.",
|
|
41
|
+
);
|
|
42
|
+
console.log("While nginx ingress is running, `vellum tunnel` targets it.");
|
|
43
|
+
console.log("");
|
|
44
|
+
console.log("Subcommands:");
|
|
45
|
+
console.log(" up Generate the nginx config and start the proxy");
|
|
46
|
+
console.log(" down Stop the proxy");
|
|
47
|
+
console.log(" status Show whether the proxy is running and where");
|
|
48
|
+
console.log("");
|
|
49
|
+
console.log("Arguments:");
|
|
50
|
+
console.log(
|
|
51
|
+
" <name> Name of the assistant (defaults to active or only local)",
|
|
52
|
+
);
|
|
53
|
+
console.log("");
|
|
54
|
+
console.log("Options:");
|
|
55
|
+
console.log(" --help, -h Show this help");
|
|
56
|
+
console.log("");
|
|
57
|
+
console.log("Environment:");
|
|
58
|
+
console.log(
|
|
59
|
+
` VELLUM_NGINX_INGRESS_PORT nginx ingress loopback listen port (default ${DEFAULT_NGINX_INGRESS_PORT})`,
|
|
60
|
+
);
|
|
61
|
+
console.log(" NGINX_BIN Path to the nginx binary");
|
|
62
|
+
console.log("");
|
|
63
|
+
console.log("Examples:");
|
|
64
|
+
console.log(" $ vellum nginx-ingress up");
|
|
65
|
+
console.log(" $ vellum nginx-ingress status");
|
|
66
|
+
console.log(" $ vellum nginx-ingress down my-assistant");
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log("Feature flags:");
|
|
69
|
+
console.log(
|
|
70
|
+
` ${WEB_REMOTE_INGRESS_FLAG} must be enabled to start nginx ingress`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface NginxIngressTarget {
|
|
75
|
+
assistantId?: string;
|
|
76
|
+
workspaceDir: string;
|
|
77
|
+
gatewayPort: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parsePortFromUrl(url: unknown): number | undefined {
|
|
81
|
+
if (typeof url !== "string" || !url.trim()) return undefined;
|
|
82
|
+
try {
|
|
83
|
+
const port = Number(new URL(url).port);
|
|
84
|
+
return Number.isInteger(port) && port > 0 && port <= 65535
|
|
85
|
+
? port
|
|
86
|
+
: undefined;
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveEntryGatewayPort(entry: AssistantEntry | undefined): number {
|
|
93
|
+
return (
|
|
94
|
+
parsePortFromUrl(entry?.localUrl) ??
|
|
95
|
+
parsePortFromUrl(entry?.runtimeUrl) ??
|
|
96
|
+
GATEWAY_PORT
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve which assistant nginx ingress fronts. Multi-instance hatches allocate
|
|
102
|
+
* per-assistant gateway ports and workspaces, so both must come from the
|
|
103
|
+
* resolved entry's resources. Entries without resources still record their
|
|
104
|
+
* reachable gateway URL, so derive the port from localUrl/runtimeUrl before
|
|
105
|
+
* falling back to the legacy default. Explicit names go through the shared
|
|
106
|
+
* identifier lookup (see cli/AGENTS.md "Assistant targeting convention") so
|
|
107
|
+
* display names resolve and ambiguous matches fail loudly.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveNginxIngressTarget(
|
|
110
|
+
assistantName: string | null,
|
|
111
|
+
): NginxIngressTarget {
|
|
112
|
+
let entry: AssistantEntry | undefined;
|
|
113
|
+
if (assistantName) {
|
|
114
|
+
const result = lookupAssistantByIdentifier(assistantName);
|
|
115
|
+
if (result.status !== "found") {
|
|
116
|
+
throw new Error(formatAssistantLookupError(assistantName, result));
|
|
117
|
+
}
|
|
118
|
+
entry = result.entry;
|
|
119
|
+
} else {
|
|
120
|
+
entry = resolveAssistant() ?? undefined;
|
|
121
|
+
}
|
|
122
|
+
if (entry?.resources) {
|
|
123
|
+
return {
|
|
124
|
+
assistantId: entry.assistantId,
|
|
125
|
+
workspaceDir: join(entry.resources.instanceDir, ".vellum", "workspace"),
|
|
126
|
+
gatewayPort: entry.resources.gatewayPort,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
assistantId: entry?.assistantId,
|
|
131
|
+
workspaceDir:
|
|
132
|
+
process.env.VELLUM_WORKSPACE_DIR?.trim() ||
|
|
133
|
+
join(homedir(), ".vellum", "workspace"),
|
|
134
|
+
gatewayPort: resolveEntryGatewayPort(entry),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function assertWebRemoteIngressEnabled(
|
|
139
|
+
target: NginxIngressTarget,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
if (!target.assistantId) {
|
|
142
|
+
throw new Error(formatFeatureFlagGateMessage(WEB_REMOTE_INGRESS_FLAG));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let enabled: boolean;
|
|
146
|
+
try {
|
|
147
|
+
enabled = await isAssistantFeatureFlagEnabled(
|
|
148
|
+
target.assistantId,
|
|
149
|
+
WEB_REMOTE_INGRESS_FLAG,
|
|
150
|
+
{ runtimeUrl: `http://127.0.0.1:${target.gatewayPort}` },
|
|
151
|
+
);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag. Is the assistant running? Try \`vellum wake\` and retry. ${
|
|
155
|
+
err instanceof Error ? err.message : String(err)
|
|
156
|
+
}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!enabled) {
|
|
161
|
+
throw new Error(formatFeatureFlagGateMessage(WEB_REMOTE_INGRESS_FLAG));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function up(target: NginxIngressTarget): Promise<void> {
|
|
166
|
+
const { workspaceDir, gatewayPort } = target;
|
|
167
|
+
const listenPort = getNginxIngressPort();
|
|
168
|
+
|
|
169
|
+
await assertWebRemoteIngressEnabled(target);
|
|
170
|
+
|
|
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 apps/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({
|
|
212
|
+
workspaceDir,
|
|
213
|
+
gatewayPort,
|
|
214
|
+
listenPort,
|
|
215
|
+
remoteWebIngress: { webDistDir },
|
|
216
|
+
});
|
|
217
|
+
child.unref();
|
|
218
|
+
|
|
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);
|
|
230
|
+
}
|
|
231
|
+
|
|
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");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function down(target: NginxIngressTarget): Promise<void> {
|
|
243
|
+
const stopped = await stopIngressNginx(target.workspaceDir);
|
|
244
|
+
if (!stopped && isIngressRunning(target.workspaceDir)) {
|
|
245
|
+
console.error("Error: nginx ingress is still running; could not stop it.");
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
console.log(
|
|
249
|
+
stopped ? "nginx ingress stopped." : "nginx ingress is not running.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function status(target: NginxIngressTarget): Promise<void> {
|
|
254
|
+
const { workspaceDir, gatewayPort } = target;
|
|
255
|
+
const { confPath, logPath } = getIngressPaths(workspaceDir);
|
|
256
|
+
const pid = getIngressPid(workspaceDir);
|
|
257
|
+
if (pid === null) {
|
|
258
|
+
console.log("nginx ingress: not running");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const { port } = resolveTunnelTargetPort(workspaceDir, gatewayPort);
|
|
262
|
+
console.log("nginx ingress: running");
|
|
263
|
+
console.log(` PID: ${pid}`);
|
|
264
|
+
console.log(` Listen: http://127.0.0.1:${port}`);
|
|
265
|
+
console.log(` Gateway: http://127.0.0.1:${gatewayPort}`);
|
|
266
|
+
console.log(` Config: ${confPath}`);
|
|
267
|
+
console.log(` Log: ${logPath}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function nginxIngress(): Promise<void> {
|
|
271
|
+
const args = process.argv.slice(3);
|
|
272
|
+
const sub = args[0];
|
|
273
|
+
|
|
274
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
275
|
+
printHelp();
|
|
276
|
+
process.exit(sub ? 0 : 1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Joins all remaining positionals so unquoted multi-word display names
|
|
280
|
+
// resolve as one identifier (cli/AGENTS.md "Assistant targeting convention").
|
|
281
|
+
const assistantName = parseAssistantTargetArg(args.slice(1));
|
|
282
|
+
const target = resolveNginxIngressTarget(assistantName ?? null);
|
|
283
|
+
|
|
284
|
+
if (sub === "up") return up(target);
|
|
285
|
+
if (sub === "down") return down(target);
|
|
286
|
+
if (sub === "status") return status(target);
|
|
287
|
+
|
|
288
|
+
console.error(`Error: Unknown subcommand '${sub}'.`);
|
|
289
|
+
console.error("Run 'vellum nginx-ingress --help' for usage.");
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
package/src/commands/rollback.ts
CHANGED
|
@@ -2,7 +2,6 @@ import {
|
|
|
2
2
|
findAssistantByName,
|
|
3
3
|
getActiveAssistant,
|
|
4
4
|
loadAllAssistants,
|
|
5
|
-
normalizeVersion,
|
|
6
5
|
resolveCloud,
|
|
7
6
|
saveAssistantEntry,
|
|
8
7
|
type AssistantEntry,
|
|
@@ -403,11 +402,6 @@ export async function rollback(): Promise<void> {
|
|
|
403
402
|
networkName: res.network,
|
|
404
403
|
},
|
|
405
404
|
previousContainerInfo: entry.containerInfo,
|
|
406
|
-
// Cleared (not preserved) when the rolled-back-to version is unknown
|
|
407
|
-
version:
|
|
408
|
-
previousVersion !== "unknown"
|
|
409
|
-
? normalizeVersion(previousVersion)
|
|
410
|
-
: undefined,
|
|
411
405
|
// Clear the backup path — it belonged to the upgrade we just rolled back
|
|
412
406
|
preUpgradeBackupPath: undefined,
|
|
413
407
|
previousDbMigrationVersion: undefined,
|
package/src/commands/sleep.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "../lib/assistant-config.js";
|
|
8
8
|
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
9
9
|
import { dockerResourceNames, sleepContainers } from "../lib/docker.js";
|
|
10
|
+
import { stopIngressNginx } from "../lib/nginx-ingress.js";
|
|
10
11
|
import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
|
|
11
12
|
|
|
12
13
|
const ACTIVE_CALL_LEASES_FILE = "active-call-leases.json";
|
|
@@ -134,9 +135,18 @@ export async function sleep(): Promise<void> {
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
// Stop assistant — use a generous timeout. On SIGTERM the daemon runs a
|
|
139
|
+
// WAL checkpoint before exiting, which can take several seconds on a
|
|
140
|
+
// multi-GB database. The default 2s grace in stopProcess() would SIGKILL a
|
|
141
|
+
// healthy daemon mid-checkpoint, forcing a costly multi-minute WAL recovery
|
|
142
|
+
// on the next start. The timeout is only a SIGKILL ceiling — stopProcess
|
|
143
|
+
// returns as soon as the process exits, so this adds no delay in the common
|
|
144
|
+
// case and only applies when the daemon is genuinely wedged.
|
|
137
145
|
const assistantStopped = await stopProcessByPidFile(
|
|
138
146
|
assistantPidFile,
|
|
139
147
|
"assistant",
|
|
148
|
+
undefined,
|
|
149
|
+
120_000,
|
|
140
150
|
);
|
|
141
151
|
if (!assistantStopped) {
|
|
142
152
|
console.log("Assistant is not running.");
|
|
@@ -157,4 +167,11 @@ export async function sleep(): Promise<void> {
|
|
|
157
167
|
} else {
|
|
158
168
|
console.log("Gateway stopped.");
|
|
159
169
|
}
|
|
170
|
+
|
|
171
|
+
// Stop the nginx ingress if one is fronting this gateway — otherwise it
|
|
172
|
+
// keeps running against a dead upstream and serves 502s.
|
|
173
|
+
const ingressStopped = await stopIngressNginx(join(vellumDir, "workspace"));
|
|
174
|
+
if (ingressStopped) {
|
|
175
|
+
console.log("nginx ingress stopped.");
|
|
176
|
+
}
|
|
160
177
|
}
|