@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.
Files changed (51) hide show
  1. package/bun.lock +49 -56
  2. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
  3. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
  4. package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
  5. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
  7. package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
  8. package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
  9. package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
  10. package/package.json +3 -3
  11. package/src/__tests__/assistant-config.test.ts +1 -2
  12. package/src/__tests__/device-id.test.ts +6 -14
  13. package/src/__tests__/helpers/os-mock.ts +27 -0
  14. package/src/__tests__/login-loopback.test.ts +71 -0
  15. package/src/__tests__/multi-local.test.ts +2 -10
  16. package/src/__tests__/nginx-ingress-command.test.ts +69 -0
  17. package/src/__tests__/nginx-ingress.test.ts +401 -0
  18. package/src/__tests__/sleep.test.ts +4 -0
  19. package/src/__tests__/teleport.test.ts +6 -9
  20. package/src/__tests__/tunnel.test.ts +164 -0
  21. package/src/__tests__/wake.test.ts +15 -4
  22. package/src/__tests__/workos-pkce.test.ts +314 -0
  23. package/src/commands/flags.ts +1 -22
  24. package/src/commands/hatch.ts +90 -9
  25. package/src/commands/login.ts +123 -59
  26. package/src/commands/nginx-ingress.ts +291 -0
  27. package/src/commands/rollback.ts +0 -6
  28. package/src/commands/sleep.ts +17 -0
  29. package/src/commands/teleport.ts +23 -36
  30. package/src/commands/tunnel.ts +69 -11
  31. package/src/commands/upgrade.ts +0 -2
  32. package/src/commands/wake.ts +7 -5
  33. package/src/commands/workflows.ts +301 -0
  34. package/src/index.ts +8 -0
  35. package/src/lib/arg-utils.ts +48 -0
  36. package/src/lib/assistant-client.ts +2 -0
  37. package/src/lib/assistant-config.ts +0 -7
  38. package/src/lib/cloudflare-tunnel.ts +15 -2
  39. package/src/lib/docker.ts +103 -49
  40. package/src/lib/feature-flags.test.ts +157 -0
  41. package/src/lib/feature-flags.ts +38 -0
  42. package/src/lib/hatch-local.ts +0 -1
  43. package/src/lib/local.ts +5 -0
  44. package/src/lib/nginx-ingress.ts +574 -0
  45. package/src/lib/ngrok.ts +26 -4
  46. package/src/lib/platform-client.ts +0 -1
  47. package/src/lib/retire-local.ts +5 -0
  48. package/src/lib/statefulset.ts +73 -21
  49. package/src/lib/sync-cloud-assistants.ts +4 -17
  50. package/src/lib/upgrade-lifecycle.ts +1 -2
  51. package/src/lib/workos-pkce.ts +160 -0
@@ -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(title: string, subtitle: string, success: boolean): string {
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
- * Start a local HTTP server, open the browser to the platform login page,
180
- * and wait for the platform to redirect back with the session token.
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 browserLogin(webUrl: string): Promise<string> {
183
- return new Promise((resolve, reject) => {
184
- const state = randomBytes(32).toString("hex");
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 ?? "/", `http://localhost`);
188
-
189
- if (url.pathname !== "/callback") {
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 receivedState = url.searchParams.get("state");
196
- const sessionToken = url.searchParams.get("session_token");
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(renderLoginPage("Login Failed", "State mismatch. Please try again.", false));
201
- cleanup("State mismatch — possible CSRF attack.");
202
- return;
203
- }
204
-
205
- if (!sessionToken) {
206
- res.writeHead(400, { "Content-Type": "text/html" });
207
- res.end(renderLoginPage("Login Failed", "No session token received. Please try again.", false));
208
- cleanup("No session token received from platform.");
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(renderLoginPage("Login Successful", "You can close this window and return to your terminal.", true));
214
- cleanup(null, sessionToken);
215
- });
216
-
217
- const timeout = setTimeout(() => {
218
- cleanup("Login timed out. Please try again.");
219
- }, LOGIN_TIMEOUT_MS);
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
- if (error) {
225
- reject(new Error(error));
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", (err) => cleanup(err.message));
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
- cleanup("Failed to start local server.");
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
- const port = addr.port;
242
- const returnTo = `/accounts/cli/callback?port=${port}&state=${state}`;
243
- const loginUrl = `${webUrl}/account/login?returnTo=${encodeURIComponent(returnTo)}`;
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
- console.log("Opening browser for login...");
246
- console.log(`If the browser doesn't open, visit: ${loginUrl}`);
247
- openBrowser(loginUrl);
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 browser-based login
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 browserLogin(webUrl);
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
+ }
@@ -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,
@@ -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
  }