@vellumai/cli 0.11.2 → 0.11.3-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.
Files changed (97) hide show
  1. package/knip.json +1 -0
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  4. package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  6. package/node_modules/@vellumai/environments/src/index.ts +17 -6
  7. package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  8. package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  9. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
  11. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
  12. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
  13. package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
  14. package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
  15. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
  16. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
  17. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  18. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  19. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  20. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  21. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  22. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
  23. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  24. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  25. package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
  26. package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
  27. package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
  28. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
  29. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
  30. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
  31. package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
  32. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
  33. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
  34. package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
  35. package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
  36. package/node_modules/@vellumai/local-mode/package.json +1 -0
  37. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
  38. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
  39. package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
  40. package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
  41. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
  42. package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
  43. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
  44. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
  45. package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
  46. package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
  47. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
  48. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
  49. package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
  50. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
  51. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
  52. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
  53. package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
  54. package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
  55. package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
  56. package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
  57. package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
  58. package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
  59. package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
  60. package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
  61. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
  62. package/package.json +3 -1
  63. package/src/__tests__/client-token.test.ts +26 -1
  64. package/src/__tests__/guardian-token.test.ts +4 -0
  65. package/src/__tests__/nginx-ingress-command.test.ts +176 -16
  66. package/src/__tests__/nginx-ingress.test.ts +1821 -101
  67. package/src/__tests__/pair.test.ts +104 -2
  68. package/src/__tests__/sleep.test.ts +7 -3
  69. package/src/__tests__/tailscale-tunnel.test.ts +0 -1
  70. package/src/__tests__/tunnel.test.ts +1099 -55
  71. package/src/__tests__/wake.test.ts +166 -99
  72. package/src/commands/client.ts +182 -27
  73. package/src/commands/connect/import.ts +35 -156
  74. package/src/commands/nginx-ingress.ts +87 -110
  75. package/src/commands/pair.ts +84 -21
  76. package/src/commands/tunnel.ts +196 -85
  77. package/src/commands/upgrade.ts +3 -2
  78. package/src/commands/wake.ts +5 -163
  79. package/src/index.ts +1 -1
  80. package/src/lib/__tests__/local-ces.test.ts +10 -1
  81. package/src/lib/cloudflare-tunnel.ts +3 -16
  82. package/src/lib/environments/__tests__/paths.test.ts +20 -1
  83. package/src/lib/environments/paths.ts +29 -41
  84. package/src/lib/environments/resolve.ts +7 -5
  85. package/src/lib/guardian-token.ts +22 -62
  86. package/src/lib/ingress-config.ts +25 -0
  87. package/src/lib/local.ts +77 -20
  88. package/src/lib/nginx-ingress.ts +523 -76
  89. package/src/lib/ngrok.ts +154 -61
  90. package/src/lib/orphan-detection.test.ts +167 -0
  91. package/src/lib/orphan-detection.ts +40 -0
  92. package/src/lib/pair.test.ts +87 -0
  93. package/src/lib/pair.ts +55 -0
  94. package/src/lib/platform-client.ts +8 -5
  95. package/src/lib/tailscale-tunnel.ts +7 -18
  96. package/src/lib/tunnel-edge.ts +114 -0
  97. package/src/lib/xdg-log.ts +2 -2
@@ -3,24 +3,20 @@
3
3
  *
4
4
  * Import a pairing bundle printed by `vellum pair` on another machine and
5
5
  * register it locally so `vellum client`/`message`/`events <name>` work against
6
- * the remote assistant.
7
- *
8
- * The bundle is base64(JSON.stringify({ gatewayUrl, assistantId, token,
9
- * deviceId })). We store the entry under a UNIQUE LOCAL id (not the bundle's
10
- * assistantId, which is typically "self" and would collide across hosts). This
11
- * is safe because the gateway's runtime proxy strips the `/v1/assistants/<id>/`
12
- * segment before forwarding, so the local id never has to match the remote one
13
- * — the token (validated by signature/audience) is what authorizes requests.
6
+ * the remote assistant. Decoding, id derivation, and the lockfile/token writes
7
+ * live in `pairAssistant` (`@vellumai/local-mode`); this command owns only the
8
+ * argv parsing and output copy.
14
9
  */
15
10
 
16
- import { nanoid } from "nanoid";
11
+ import {
12
+ decodePairBundle,
13
+ pairAssistant,
14
+ resolveConfigDir,
15
+ } from "@vellumai/local-mode";
17
16
 
18
17
  import { extractFlag } from "../../lib/arg-utils.js";
19
- import {
20
- findAssistantByName,
21
- saveAssistantEntry,
22
- } from "../../lib/assistant-config.js";
23
- import { saveGuardianToken } from "../../lib/guardian-token.js";
18
+ import { getLockfilePaths } from "../../lib/environments/paths.js";
19
+ import { getCurrentEnvironment } from "../../lib/environments/resolve.js";
24
20
 
25
21
  function printUsage(): void {
26
22
  console.log(`vellum connect import - Register an assistant paired from another machine
@@ -41,85 +37,6 @@ EXAMPLES:
41
37
  `);
42
38
  }
43
39
 
44
- interface PairBundle {
45
- gatewayUrl: string;
46
- token: string;
47
- assistantId?: string;
48
- deviceId?: string;
49
- // Optional refresh credential. Present when the host's gateway issued a
50
- // device-bound token pair; absent for older access-only bundles (which remain
51
- // importable, just without auto-renewal). `refreshTokenExpiresAt` mirrors
52
- // GuardianTokenData (ISO string OR epoch-ms number) so a numeric expiry isn't
53
- // silently dropped on import.
54
- refreshToken?: string;
55
- refreshTokenExpiresAt?: string | number;
56
- refreshAfter?: string;
57
- }
58
-
59
- /** Decode the base64 bundle, returning null if malformed or missing fields. */
60
- function decodeBundle(blob: string): PairBundle | null {
61
- let json: unknown;
62
- try {
63
- json = JSON.parse(Buffer.from(blob, "base64").toString("utf8"));
64
- } catch {
65
- return null;
66
- }
67
- if (typeof json !== "object" || json === null) return null;
68
- const b = json as Record<string, unknown>;
69
- if (typeof b.gatewayUrl !== "string" || typeof b.token !== "string") {
70
- return null;
71
- }
72
- // The gatewayUrl is persisted as runtimeUrl and used to build fetch URLs, so
73
- // require an absolute http(s) URL here rather than letting an invalid string
74
- // through (which would crash `new URL(...)` or break later client calls).
75
- try {
76
- const parsed = new URL(b.gatewayUrl);
77
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
78
- return null;
79
- }
80
- } catch {
81
- return null;
82
- }
83
- return {
84
- gatewayUrl: b.gatewayUrl,
85
- token: b.token,
86
- assistantId: typeof b.assistantId === "string" ? b.assistantId : undefined,
87
- deviceId: typeof b.deviceId === "string" ? b.deviceId : undefined,
88
- refreshToken:
89
- typeof b.refreshToken === "string" ? b.refreshToken : undefined,
90
- refreshTokenExpiresAt:
91
- typeof b.refreshTokenExpiresAt === "string" ||
92
- typeof b.refreshTokenExpiresAt === "number"
93
- ? b.refreshTokenExpiresAt
94
- : undefined,
95
- refreshAfter:
96
- typeof b.refreshAfter === "string" ? b.refreshAfter : undefined,
97
- };
98
- }
99
-
100
- /** Lowercase, collapse non-alphanumerics to single dashes, trim dashes. */
101
- function slugify(name: string): string {
102
- return name
103
- .toLowerCase()
104
- .replace(/[^a-z0-9]+/g, "-")
105
- .replace(/^-+|-+$/g, "");
106
- }
107
-
108
- /** Best-effort JWT `exp` (epoch seconds) → epoch ms; null if undecodable. */
109
- function jwtExpiryMs(token: string): number | null {
110
- const parts = token.split(".");
111
- if (parts.length !== 3) return null;
112
- try {
113
- const payload = JSON.parse(
114
- Buffer.from(parts[1], "base64").toString("utf8"),
115
- );
116
- if (typeof payload.exp === "number") return payload.exp * 1000;
117
- } catch {
118
- /* fall through */
119
- }
120
- return null;
121
- }
122
-
123
40
  export async function connectImport(): Promise<void> {
124
41
  const rawArgs = process.argv.slice(4);
125
42
 
@@ -136,82 +53,44 @@ export async function connectImport(): Promise<void> {
136
53
  process.exit(1);
137
54
  }
138
55
 
139
- const bundle = decodeBundle(blob);
140
- if (!bundle) {
56
+ const decoded = decodePairBundle(blob);
57
+ if (!decoded.ok) {
141
58
  console.error(
142
59
  "Error: invalid pairing bundle. Paste the full base64 string printed by `vellum pair`.",
143
60
  );
144
61
  process.exit(1);
145
62
  }
146
63
 
147
- // Unique local id: a --name slug, or paired-<deviceId> (deviceId is unique
148
- // per pairing). Never the bundle's "self" assistantId — that would collide.
149
- // The deviceId comes from an untrusted bundle and is used as a path component
150
- // by saveGuardianToken, so it MUST be slugified (no `../` traversal); fall
151
- // back to a random id if it sanitizes to empty.
152
- const localId = nameFlag
153
- ? slugify(nameFlag)
154
- : `paired-${slugify(bundle.deviceId ?? "") || nanoid()}`;
155
- if (!localId) {
156
- console.error(
157
- "Error: --name must contain at least one alphanumeric character.",
158
- );
159
- process.exit(1);
160
- }
161
-
162
- // Don't clobber an existing assistant. Only update in place when the prior
163
- // entry is itself a paired import (marked `paired: true`); otherwise the id
164
- // collides with a real local/remote assistant and overwriting would drop its
165
- // resources/runtime metadata. Reject and let the user pick a fresh --name.
166
- const existing = findAssistantByName(localId);
167
- if (existing && existing.paired !== true) {
168
- console.error(
169
- `Error: an assistant named '${localId}' already exists locally. ` +
170
- "Choose a different --name to avoid overwriting it.",
171
- );
64
+ const result = pairAssistant(
65
+ getLockfilePaths(getCurrentEnvironment()),
66
+ resolveConfigDir(process.env),
67
+ { bundle: decoded.bundle, name: nameFlag },
68
+ );
69
+ if (!result.ok) {
70
+ if (result.status === 400) {
71
+ console.error(
72
+ "Error: --name must contain at least one alphanumeric character.",
73
+ );
74
+ } else if (result.status === 409) {
75
+ console.error(
76
+ `Error: an assistant named '${result.assistantId}' already exists locally. ` +
77
+ "Choose a different --name to avoid overwriting it.",
78
+ );
79
+ } else {
80
+ console.error(`Error: ${result.error}`);
81
+ }
172
82
  process.exit(1);
173
83
  }
174
- const existed = existing !== null;
175
-
176
- saveAssistantEntry({
177
- assistantId: localId,
178
- name: nameFlag ?? `paired (${new URL(bundle.gatewayUrl).host})`,
179
- runtimeUrl: bundle.gatewayUrl,
180
- // Paired entries are reached by bearer token at the remote runtimeUrl
181
- // (a non-"vellum" cloud selects the bearer-token auth path in client.ts).
182
- // The "paired" topology lets lifecycle/status commands (ps/wake/sleep)
183
- // recognize this as a remote pairing rather than an on-machine process.
184
- cloud: "paired",
185
- // Marks this entry as a connect-import so re-imports update in place while
186
- // imports never silently overwrite a non-paired assistant (see guard above).
187
- paired: true,
188
- species: "vellum",
189
- });
190
-
191
- const now = Date.now();
192
- const hasRefresh = Boolean(bundle.refreshToken);
193
- saveGuardianToken(localId, {
194
- guardianPrincipalId: "imported",
195
- accessToken: bundle.token,
196
- accessTokenExpiresAt:
197
- jwtExpiryMs(bundle.token) ?? now + 24 * 60 * 60 * 1000,
198
- refreshToken: bundle.refreshToken ?? "",
199
- refreshTokenExpiresAt: bundle.refreshTokenExpiresAt ?? 0,
200
- refreshAfter: bundle.refreshAfter ?? "",
201
- isNew: false,
202
- deviceId: bundle.deviceId ?? "",
203
- leasedAt: new Date(now).toISOString(),
204
- });
205
84
 
206
85
  console.log(
207
- `${existed ? "Updated" : "Imported"} paired assistant '${localId}'.`,
86
+ `${result.updated ? "Updated" : "Imported"} paired assistant '${result.assistantId}'.`,
208
87
  );
209
88
  console.log("");
210
- console.log(` Connect with: vellum client ${localId}`);
89
+ console.log(` Connect with: vellum client ${result.assistantId}`);
211
90
  console.log("");
212
91
  console.log(
213
- hasRefresh
214
- ? "Note: this connection includes a refresh credential, so it can renew itself re-pair only if it's revoked or the refresh credential expires."
215
- : "Note: the token is access-only and will expire re-run `vellum pair` and import again when it does.",
92
+ result.accessOnly
93
+ ? "Note: the token is access-only and will expire. Re-run `vellum pair` and import again when it does."
94
+ : "Note: this connection includes a refresh credential, so it can renew itself. Re-pair only if it's revoked or the refresh credential expires.",
216
95
  );
217
96
  }
@@ -9,36 +9,39 @@ import {
9
9
  import type { AssistantEntry } from "../lib/assistant-config.js";
10
10
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
11
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";
12
+ import { WEB_REMOTE_INGRESS_FLAG } from "../lib/feature-flags.js";
17
13
  import {
18
14
  DEFAULT_NGINX_INGRESS_PORT,
15
+ ensureTunnelEdge,
16
+ formatEdgeMode,
19
17
  getIngressPaths,
20
18
  getIngressPid,
19
+ getNginxIngressPort,
21
20
  isIngressRunning,
22
- resolveTunnelTargetPort,
23
- startRemoteWebIngress,
21
+ readIngressState,
24
22
  stopIngressNginx,
23
+ type TunnelEdge,
25
24
  } from "../lib/nginx-ingress.js";
26
25
 
27
26
  function printHelp(): void {
28
27
  console.log("Usage: vellum nginx-ingress <subcommand> [<name>] [options]");
29
28
  console.log("");
29
+ console.log("Manage the nginx edge that fronts the gateway for tunnel");
30
+ console.log("traffic: browser/webhooks → tunnel (TLS) → nginx@127.0.0.1.");
30
31
  console.log(
31
- "Manage the nginx web edge that serves the SPA and fronts the gateway",
32
+ "`vellum tunnel` starts or reuses this edge automatically; use this",
32
33
  );
33
34
  console.log(
34
- "for remote web access: browser tunnel (TLS) nginx@127.0.0.1.",
35
+ "command as plumbing: check status, stop the edge, or start it for a",
35
36
  );
36
- console.log("While nginx ingress is running, `vellum tunnel` targets it.");
37
+ console.log("bring-your-own HTTPS front (e.g. `tailscale serve`).");
37
38
  console.log("");
38
39
  console.log("Subcommands:");
39
- console.log(" up Generate the nginx config and start the proxy");
40
- console.log(" down Stop the proxy");
41
- console.log(" status Show whether the proxy is running and where");
40
+ console.log(" up Generate the nginx config and start the edge");
41
+ console.log(" down Stop the edge");
42
+ console.log(
43
+ " status Show whether the edge is running, its mode, and where",
44
+ );
42
45
  console.log("");
43
46
  console.log("Arguments:");
44
47
  console.log(
@@ -50,19 +53,23 @@ function printHelp(): void {
50
53
  console.log("");
51
54
  console.log("Environment:");
52
55
  console.log(
53
- ` VELLUM_NGINX_INGRESS_PORT nginx ingress loopback listen port (default ${DEFAULT_NGINX_INGRESS_PORT})`,
56
+ ` VELLUM_NGINX_INGRESS_PORT nginx edge loopback listen port (default ${DEFAULT_NGINX_INGRESS_PORT})`,
54
57
  );
55
58
  console.log(" NGINX_BIN Path to the nginx binary");
56
59
  console.log("");
57
60
  console.log("Examples:");
58
- console.log(" $ vellum nginx-ingress up");
59
61
  console.log(" $ vellum nginx-ingress status");
62
+ console.log(" $ vellum nginx-ingress up");
60
63
  console.log(" $ vellum nginx-ingress down my-assistant");
61
64
  console.log("");
62
65
  console.log("Feature flags:");
63
66
  console.log(
64
- ` ${WEB_REMOTE_INGRESS_FLAG} must be enabled to start nginx ingress`,
67
+ ` ${WEB_REMOTE_INGRESS_FLAG} selects the edge mode: enabled serves the`,
65
68
  );
69
+ console.log(
70
+ " remote web app alongside webhooks; disabled starts a webhooks-only",
71
+ );
72
+ console.log(" proxy.");
66
73
  }
67
74
 
68
75
  interface NginxIngressTarget {
@@ -83,14 +90,23 @@ function parsePortFromUrl(url: unknown): number | undefined {
83
90
  }
84
91
  }
85
92
 
86
- function resolveEntryGatewayPort(entry: AssistantEntry | undefined): number {
93
+ /**
94
+ * Derive the gateway port from an entry's recorded URLs, preferring the
95
+ * loopback `localUrl` over `runtimeUrl`. Undefined when neither carries an
96
+ * explicit port (e.g. a platform-hosted https runtime URL).
97
+ */
98
+ export function parseGatewayPortFromEntryUrls(
99
+ entry: AssistantEntry | undefined,
100
+ ): number | undefined {
87
101
  return (
88
- parsePortFromUrl(entry?.localUrl) ??
89
- parsePortFromUrl(entry?.runtimeUrl) ??
90
- GATEWAY_PORT
102
+ parsePortFromUrl(entry?.localUrl) ?? parsePortFromUrl(entry?.runtimeUrl)
91
103
  );
92
104
  }
93
105
 
106
+ function resolveEntryGatewayPort(entry: AssistantEntry | undefined): number {
107
+ return parseGatewayPortFromEntryUrls(entry) ?? GATEWAY_PORT;
108
+ }
109
+
94
110
  /**
95
111
  * Resolve which assistant nginx ingress fronts. Multi-instance hatches allocate
96
112
  * per-assistant gateway ports and workspaces, so both must come from the
@@ -129,100 +145,52 @@ export function resolveNginxIngressTarget(
129
145
  };
130
146
  }
131
147
 
132
- async function assertWebRemoteIngressEnabled(
133
- target: NginxIngressTarget,
134
- ): Promise<void> {
135
- if (!target.assistantId) {
136
- throw new Error(formatFeatureFlagGateMessage(WEB_REMOTE_INGRESS_FLAG));
137
- }
148
+ export async function up(target: NginxIngressTarget): Promise<void> {
149
+ const { workspaceDir, gatewayPort } = target;
138
150
 
139
- let enabled: boolean;
151
+ let edge: TunnelEdge;
140
152
  try {
141
- enabled = await isAssistantFeatureFlagEnabled(
142
- target.assistantId,
143
- WEB_REMOTE_INGRESS_FLAG,
144
- { runtimeUrl: `http://127.0.0.1:${target.gatewayPort}` },
145
- );
153
+ edge = await ensureTunnelEdge({
154
+ assistantId: target.assistantId,
155
+ workspaceDir,
156
+ gatewayPort,
157
+ onStarting: ({ version, webDistDir, listenPort }) => {
158
+ console.log(`Using ${version}`);
159
+ const webSegment = webDistDir ? `web ${webDistDir} + ` : "";
160
+ console.log(
161
+ `Starting nginx ingress on 127.0.0.1:${listenPort} → ${webSegment}gateway 127.0.0.1:${gatewayPort}...`,
162
+ );
163
+ },
164
+ });
146
165
  } catch (err) {
147
- throw new Error(
148
- `Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag. Is the assistant running? Try \`vellum wake\` and retry. ${
149
- err instanceof Error ? err.message : String(err)
150
- }`,
151
- );
166
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
167
+ process.exit(1);
152
168
  }
153
169
 
154
- if (!enabled) {
155
- throw new Error(formatFeatureFlagGateMessage(WEB_REMOTE_INGRESS_FLAG));
156
- }
157
- }
158
-
159
- async function up(target: NginxIngressTarget): Promise<void> {
160
- const { workspaceDir, gatewayPort } = target;
170
+ const mode = formatEdgeMode(edge.includesWebApp);
161
171
 
162
- await assertWebRemoteIngressEnabled(target);
163
-
164
- const result = await startRemoteWebIngress({
165
- workspaceDir,
166
- gatewayPort,
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
- },
173
- });
174
-
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;
172
+ if (!edge.started) {
173
+ console.log(`nginx ingress is already running (${mode}).`);
174
+ await status(target);
175
+ return;
223
176
  }
224
177
 
225
- process.exit(1);
178
+ console.log("");
179
+ console.log(`nginx ingress running: http://127.0.0.1:${edge.port} (${mode})`);
180
+ if (!edge.includesWebApp) {
181
+ console.log(
182
+ `Enable the ${WEB_REMOTE_INGRESS_FLAG} feature flag to also serve the web app remotely.`,
183
+ );
184
+ }
185
+ console.log("");
186
+ console.log("Next steps:");
187
+ console.log(
188
+ " vellum tunnel --provider ngrok # put an HTTPS front on this edge",
189
+ );
190
+ console.log(
191
+ " # (cloudflare and tailscale work too)",
192
+ );
193
+ console.log(" vellum nginx-ingress down # stop the edge");
226
194
  }
227
195
 
228
196
  async function down(target: NginxIngressTarget): Promise<void> {
@@ -236,7 +204,7 @@ async function down(target: NginxIngressTarget): Promise<void> {
236
204
  );
237
205
  }
238
206
 
239
- async function status(target: NginxIngressTarget): Promise<void> {
207
+ export async function status(target: NginxIngressTarget): Promise<void> {
240
208
  const { workspaceDir, gatewayPort } = target;
241
209
  const { confPath, logPath } = getIngressPaths(workspaceDir);
242
210
  const pid = getIngressPid(workspaceDir);
@@ -244,11 +212,20 @@ async function status(target: NginxIngressTarget): Promise<void> {
244
212
  console.log("nginx ingress: not running");
245
213
  return;
246
214
  }
247
- const { port } = resolveTunnelTargetPort(workspaceDir, gatewayPort);
215
+ const state = readIngressState(workspaceDir);
216
+ const listenPort = state?.listenPort ?? getNginxIngressPort();
217
+ const mode = formatEdgeMode(state?.includeWebApp ?? true);
218
+ // The recorded gateway port is what the running edge proxies; a state
219
+ // record without gatewayPort is unverified.
220
+ const gatewayLine =
221
+ state?.gatewayPort !== undefined
222
+ ? `http://127.0.0.1:${state.gatewayPort}`
223
+ : `http://127.0.0.1:${gatewayPort} (unverified)`;
248
224
  console.log("nginx ingress: running");
249
225
  console.log(` PID: ${pid}`);
250
- console.log(` Listen: http://127.0.0.1:${port}`);
251
- console.log(` Gateway: http://127.0.0.1:${gatewayPort}`);
226
+ console.log(` Mode: ${mode}`);
227
+ console.log(` Listen: http://127.0.0.1:${listenPort}`);
228
+ console.log(` Gateway: ${gatewayLine}`);
252
229
  console.log(` Config: ${confPath}`);
253
230
  console.log(` Log: ${logPath}`);
254
231
  }