@vellumai/cli 0.8.7 → 0.8.8-dev.202606052332.17fc8ea

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 (49) hide show
  1. package/node_modules/@vellumai/local-mode/package.json +2 -1
  2. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  3. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  4. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +15 -0
  5. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  6. package/node_modules/@vellumai/local-mode/src/config.ts +15 -8
  7. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  8. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +42 -0
  9. package/node_modules/@vellumai/local-mode/src/hatch.ts +22 -4
  10. package/node_modules/@vellumai/local-mode/src/index.ts +26 -4
  11. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  12. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  13. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  14. package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -7
  15. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  16. package/package.json +1 -1
  17. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  18. package/src/__tests__/clean.test.ts +179 -0
  19. package/src/__tests__/client-token.test.ts +87 -0
  20. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  21. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  22. package/src/__tests__/connect-import.test.ts +317 -0
  23. package/src/__tests__/devices.test.ts +272 -0
  24. package/src/__tests__/guardian-token.test.ts +126 -2
  25. package/src/__tests__/pair.test.ts +271 -0
  26. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  27. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  28. package/src/__tests__/unpair.test.ts +163 -0
  29. package/src/commands/client.ts +115 -26
  30. package/src/commands/connect/import.ts +217 -0
  31. package/src/commands/connect.ts +31 -0
  32. package/src/commands/devices.ts +247 -0
  33. package/src/commands/pair.ts +222 -0
  34. package/src/commands/ps.ts +16 -0
  35. package/src/commands/retire.ts +20 -47
  36. package/src/commands/sleep.ts +7 -0
  37. package/src/commands/tunnel.ts +46 -2
  38. package/src/commands/unpair.ts +118 -0
  39. package/src/commands/wake.ts +7 -0
  40. package/src/components/DefaultMainScreen.tsx +84 -13
  41. package/src/index.ts +16 -0
  42. package/src/lib/assistant-client.ts +58 -37
  43. package/src/lib/assistant-config.ts +12 -0
  44. package/src/lib/cloudflare-tunnel.ts +276 -0
  45. package/src/lib/confirm-action.ts +57 -0
  46. package/src/lib/docker.ts +25 -1
  47. package/src/lib/environments/resolve.ts +9 -30
  48. package/src/lib/guardian-token.ts +120 -4
  49. package/src/lib/local.ts +20 -6
@@ -10,7 +10,9 @@ import {
10
10
  import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
11
11
 
12
12
  import { SPECIES_CONFIG, type Species } from "../lib/constants";
13
+ import { lookupAssistantByIdentifier } from "../lib/assistant-config";
13
14
  import { checkHealth } from "../lib/health-check";
15
+ import { loadGuardianToken, refreshGuardianToken } from "../lib/guardian-token";
14
16
  import { appendHistory, loadHistory } from "../lib/input-history";
15
17
  import { tuiLog } from "../lib/tui-log";
16
18
  import { segmentsToPlainText } from "../lib/segments-to-plain-text";
@@ -63,6 +65,9 @@ const HELP_COMMANDS = [
63
65
  ] as const;
64
66
 
65
67
  const SEND_TIMEOUT_MS = 5000;
68
+ /** Fresh deadline for a request retried after a mid-session token refresh —
69
+ * the original caller signal may have already timed out during the refresh. */
70
+ const RETRY_TIMEOUT_MS = 30_000;
66
71
 
67
72
  // ── Layout constants ──────────────────────────────────────
68
73
  const MAX_TOTAL_WIDTH = 72;
@@ -177,6 +182,44 @@ function friendlyErrorMessage(status: number, body: string): string {
177
182
  return `HTTP ${status}: ${body || "Unknown error"}`;
178
183
  }
179
184
 
185
+ /**
186
+ * On a 401, refresh a stale PAIRED-assistant guardian token and update the
187
+ * shared `auth` headers IN PLACE, returning true if the caller should retry.
188
+ *
189
+ * Scoped to paired assistants only (a remote assistant on another machine,
190
+ * `cloud: "paired"`) — the local/docker TUI flow and platform sessions are left
191
+ * untouched. Also self-gating: skips platform session auth (no `Authorization`
192
+ * header), ephemeral `--token` overrides (whose bearer won't match the store),
193
+ * and access-only tokens. Because the TUI threads one shared `auth` object by
194
+ * reference, mutating it here propagates to every later request and the SSE
195
+ * reconnect — no callback threading needed.
196
+ */
197
+ export async function maybeRefreshAuthHeaders(
198
+ baseUrl: string,
199
+ assistantId: string,
200
+ auth?: Record<string, string>,
201
+ ): Promise<boolean> {
202
+ if (!auth) return false;
203
+ const bearer = auth["Authorization"]?.replace(/^Bearer /, "");
204
+ if (!bearer) return false;
205
+
206
+ // Only paired (remote-on-another-machine) assistants use refreshable pair
207
+ // tokens; don't perturb the local/docker session flow.
208
+ const lookup = lookupAssistantByIdentifier(assistantId);
209
+ if (lookup.status !== "found" || lookup.entry.cloud !== "paired") {
210
+ return false;
211
+ }
212
+
213
+ const stored = loadGuardianToken(assistantId);
214
+ if (!stored || stored.accessToken !== bearer || !stored.refreshToken) {
215
+ return false;
216
+ }
217
+ const refreshed = await refreshGuardianToken(baseUrl, assistantId);
218
+ if (!refreshed?.accessToken) return false;
219
+ auth["Authorization"] = `Bearer ${refreshed.accessToken}`;
220
+ return true;
221
+ }
222
+
180
223
  async function runtimeRequest<T>(
181
224
  baseUrl: string,
182
225
  assistantId: string,
@@ -185,14 +228,30 @@ async function runtimeRequest<T>(
185
228
  auth?: Record<string, string>,
186
229
  ): Promise<T> {
187
230
  const url = `${baseUrl}/v1/assistants/${assistantId}${path}`;
188
- const response = await fetch(url, {
189
- ...init,
190
- headers: {
191
- "Content-Type": "application/json",
192
- ...auth,
193
- ...(init?.headers as Record<string, string> | undefined),
194
- },
195
- });
231
+ const doFetch = (signalOverride?: AbortSignal) =>
232
+ fetch(url, {
233
+ ...init,
234
+ // The retry overrides the caller's signal (see below); otherwise use it.
235
+ signal: signalOverride ?? init?.signal,
236
+ headers: {
237
+ "Content-Type": "application/json",
238
+ ...auth,
239
+ ...(init?.headers as Record<string, string> | undefined),
240
+ },
241
+ });
242
+
243
+ let response = await doFetch();
244
+ // Mid-session token expiry → 401: refresh once and retry (auth headers are
245
+ // mutated in place by the helper, so the retry carries the new token). The
246
+ // refresh can take longer than the caller's timeout (lock wait + refresh
247
+ // fetch), which would already have aborted the original signal — so give the
248
+ // retry a fresh deadline instead of reusing the (likely-expired) one.
249
+ if (
250
+ response.status === 401 &&
251
+ (await maybeRefreshAuthHeaders(baseUrl, assistantId, auth))
252
+ ) {
253
+ response = await doFetch(AbortSignal.timeout(RETRY_TIMEOUT_MS));
254
+ }
196
255
 
197
256
  if (!response.ok) {
198
257
  const body = await response.text().catch(() => "");
@@ -374,6 +433,11 @@ async function* streamEvents(
374
433
  const params = new URLSearchParams({ conversationKey });
375
434
  const url = `${baseUrl}/v1/assistants/${assistantId}/events?${params.toString()}`;
376
435
  tuiLog.info("sse connect", { url, authHeaders: Object.keys(auth ?? {}) });
436
+ // NOTE: the SSE connect deliberately does NOT refresh-on-401 — keeping the
437
+ // stream path simple. After a mid-session token expiry, the REST path
438
+ // (runtimeRequest) refreshes the shared `auth` on the next request, and the
439
+ // existing reconnect (ensureConnected on the next message) re-opens the
440
+ // stream with the refreshed token.
377
441
  const response = await fetch(url, {
378
442
  headers: {
379
443
  Accept: "text/event-stream",
@@ -1984,9 +2048,8 @@ function ChatApp({
1984
2048
  if (!isConnected) return;
1985
2049
 
1986
2050
  try {
1987
- const res = await fetch(
1988
- `${runtimeUrl}/v1/assistants/${assistantId}/btw`,
1989
- {
2051
+ const btwFetch = () =>
2052
+ fetch(`${runtimeUrl}/v1/assistants/${assistantId}/btw`, {
1990
2053
  method: "POST",
1991
2054
  headers: {
1992
2055
  "Content-Type": "application/json",
@@ -1997,8 +2060,16 @@ function ChatApp({
1997
2060
  content: question,
1998
2061
  }),
1999
2062
  signal: AbortSignal.timeout(30_000),
2000
- },
2001
- );
2063
+ });
2064
+
2065
+ let res = await btwFetch();
2066
+ // Mid-session token expiry → 401: refresh once and retry.
2067
+ if (
2068
+ res.status === 401 &&
2069
+ (await maybeRefreshAuthHeaders(runtimeUrl, assistantId, auth))
2070
+ ) {
2071
+ res = await btwFetch();
2072
+ }
2002
2073
 
2003
2074
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
2004
2075
 
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ import cliPkg from "../package.json";
4
4
  import { backup } from "./commands/backup";
5
5
  import { clean } from "./commands/clean";
6
6
  import { client } from "./commands/client";
7
+ import { connect } from "./commands/connect";
8
+ import { devices } from "./commands/devices";
7
9
  import { env } from "./commands/env";
8
10
  import { events } from "./commands/events";
9
11
  import { exec } from "./commands/exec";
@@ -13,6 +15,7 @@ import { hatch } from "./commands/hatch";
13
15
  import { login, logout, whoami } from "./commands/login";
14
16
  import { logs } from "./commands/logs";
15
17
  import { message } from "./commands/message";
18
+ import { pair } from "./commands/pair";
16
19
  import { ps } from "./commands/ps";
17
20
  import { recover } from "./commands/recover";
18
21
  import { restore } from "./commands/restore";
@@ -25,6 +28,7 @@ import { ssh } from "./commands/ssh";
25
28
  import { teleport } from "./commands/teleport";
26
29
  import { terminal } from "./commands/terminal";
27
30
  import { tunnel } from "./commands/tunnel";
31
+ import { unpair } from "./commands/unpair";
28
32
  import { upgrade } from "./commands/upgrade";
29
33
  import { use } from "./commands/use";
30
34
  import { wake } from "./commands/wake";
@@ -36,6 +40,8 @@ const commands = {
36
40
  backup,
37
41
  clean,
38
42
  client,
43
+ connect,
44
+ devices,
39
45
  env,
40
46
  events,
41
47
  exec,
@@ -46,6 +52,7 @@ const commands = {
46
52
  logout,
47
53
  logs,
48
54
  message,
55
+ pair,
49
56
  ps,
50
57
  recover,
51
58
  restore,
@@ -58,6 +65,7 @@ const commands = {
58
65
  teleport,
59
66
  terminal,
60
67
  tunnel,
68
+ unpair,
61
69
  upgrade,
62
70
  use,
63
71
  wake,
@@ -73,6 +81,8 @@ function printHelp(): void {
73
81
  console.log(" backup Export a backup of a running assistant");
74
82
  console.log(" clean Kill orphaned vellum processes");
75
83
  console.log(" client Connect to a hatched assistant");
84
+ console.log(" connect Import an assistant paired from another machine");
85
+ console.log(" devices List or revoke devices paired to a local assistant");
76
86
  console.log(" env Manage the default CLI environment");
77
87
  console.log(" events Stream events from a running assistant");
78
88
  console.log(" exec Execute a command inside an assistant's container");
@@ -83,6 +93,9 @@ function printHelp(): void {
83
93
  console.log(" login Log in to the Vellum platform");
84
94
  console.log(" logout Log out of the Vellum platform");
85
95
  console.log(" message Send a message to a running assistant");
96
+ console.log(
97
+ " pair Mint a device-scoped token to connect another machine",
98
+ );
86
99
  console.log(
87
100
  " ps List assistants (or processes for a specific assistant)",
88
101
  );
@@ -99,6 +112,9 @@ function printHelp(): void {
99
112
  console.log(" teleport Transfer assistant data between environments");
100
113
  console.log(" terminal Open a terminal into a managed assistant container");
101
114
  console.log(" tunnel Create a tunnel for a locally hosted assistant");
115
+ console.log(
116
+ " unpair Forget a paired assistant imported from another machine",
117
+ );
102
118
  console.log(" upgrade Upgrade an assistant to a newer version");
103
119
  console.log(" use Set the active assistant for commands");
104
120
  console.log(" wake Start the assistant and gateway");
@@ -12,11 +12,9 @@
12
12
  * ```
13
13
  */
14
14
 
15
- import {
16
- resolveAssistant,
17
- } from "./assistant-config.js";
15
+ import { resolveAssistant } from "./assistant-config.js";
18
16
  import { GATEWAY_PORT } from "./constants.js";
19
- import { loadGuardianToken } from "./guardian-token.js";
17
+ import { loadGuardianToken, refreshGuardianToken } from "./guardian-token.js";
20
18
 
21
19
  const DEFAULT_TIMEOUT_MS = 30_000;
22
20
  const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
@@ -45,7 +43,8 @@ export class AssistantClient {
45
43
  readonly runtimeUrl: string;
46
44
 
47
45
  private readonly _assistantId: string;
48
- private readonly token: string | undefined;
46
+ /** Mutable: a 401 on the guardian path refreshes this in place (see request). */
47
+ private token: string | undefined;
49
48
  /** True when token is a platform session token (X-Session-Token), false for guardian JWT (Authorization: Bearer). */
50
49
  private readonly isSessionAuth: boolean;
51
50
  private readonly orgId: string | undefined;
@@ -176,45 +175,67 @@ export class AssistantClient {
176
175
  ? `?${new URLSearchParams(opts.query).toString()}`
177
176
  : "";
178
177
  const url = `${this.runtimeUrl}/v1/assistants/${this._assistantId}${urlPath}${qs}`;
179
-
180
- const headers: Record<string, string> = { ...opts?.headers };
181
- if (this.token) {
182
- if (this.isSessionAuth) {
183
- headers["X-Session-Token"] ??= this.token;
184
- } else {
185
- headers["Authorization"] ??= `Bearer ${this.token}`;
186
- }
187
- }
188
- if (this.orgId) {
189
- headers["Vellum-Organization-Id"] ??= this.orgId;
190
- }
191
- if (body !== undefined) {
192
- headers["Content-Type"] = "application/json";
193
- }
194
-
195
178
  const jsonBody = body !== undefined ? JSON.stringify(body) : undefined;
196
179
 
197
- if (opts?.signal) {
180
+ // Headers are built per-attempt so a refreshed token is picked up on retry.
181
+ const buildHeaders = (): Record<string, string> => {
182
+ const headers: Record<string, string> = { ...opts?.headers };
183
+ if (this.token) {
184
+ if (this.isSessionAuth) {
185
+ headers["X-Session-Token"] ??= this.token;
186
+ } else {
187
+ headers["Authorization"] ??= `Bearer ${this.token}`;
188
+ }
189
+ }
190
+ if (this.orgId) {
191
+ headers["Vellum-Organization-Id"] ??= this.orgId;
192
+ }
193
+ if (body !== undefined) {
194
+ headers["Content-Type"] = "application/json";
195
+ }
196
+ return headers;
197
+ };
198
+
199
+ const doFetch = (): Promise<Response> => {
200
+ const headers = buildHeaders();
201
+ if (opts?.signal) {
202
+ return fetch(url, {
203
+ method,
204
+ headers,
205
+ body: jsonBody,
206
+ signal: opts.signal,
207
+ });
208
+ }
209
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
210
+ const controller = new AbortController();
211
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
198
212
  return fetch(url, {
199
- method,
200
- headers,
201
- body: jsonBody,
202
- signal: opts.signal,
203
- });
204
- }
205
-
206
- const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
207
- const controller = new AbortController();
208
- const timeoutId = setTimeout(() => controller.abort(), timeout);
209
- try {
210
- return await fetch(url, {
211
213
  method,
212
214
  headers,
213
215
  body: jsonBody,
214
216
  signal: controller.signal,
215
- });
216
- } finally {
217
- clearTimeout(timeoutId);
217
+ }).finally(() => clearTimeout(timeoutId));
218
+ };
219
+
220
+ const response = await doFetch();
221
+
222
+ // Reactive auto-refresh: a paired/local guardian access token that has
223
+ // expired comes back 401. Refresh it once via the stored refresh credential
224
+ // and retry. Self-gating — refreshGuardianToken returns null unless a usable
225
+ // refresh token is stored, so ephemeral (`--token`) and access-only sessions
226
+ // just see the original 401. The platform session-auth path is never
227
+ // refreshed here (its token is managed by the Vellum platform).
228
+ if (response.status === 401 && !this.isSessionAuth) {
229
+ const refreshed = await refreshGuardianToken(
230
+ this.runtimeUrl,
231
+ this._assistantId,
232
+ );
233
+ if (refreshed?.accessToken) {
234
+ this.token = refreshed.accessToken;
235
+ return doFetch();
236
+ }
218
237
  }
238
+
239
+ return response;
219
240
  }
220
241
  }
@@ -76,7 +76,19 @@ export interface AssistantEntry {
76
76
  * Avoids mDNS resolution issues when the machine checks its own gateway. */
77
77
  localUrl?: string;
78
78
  bearerToken?: string;
79
+ /** Deployment topology / how the assistant is reached. Known values:
80
+ * `"local"` (on-machine daemon), `"docker"` (local container),
81
+ * `"apple-container"` (macOS-app-managed container), `"vellum"`
82
+ * (platform-managed, uses the X-Session-Token auth path), `"gcp"` / `"aws"`
83
+ * / `"custom"` (remote, SSH-managed), and `"paired"` (a remote assistant
84
+ * paired from another machine — reached via a bearer guardian token at
85
+ * `runtimeUrl`; has no local process, container, or `resources`).
86
+ * Kept as a free `string` (not a union) for forward-compatibility. */
79
87
  cloud: string;
88
+ /** True when this entry was registered via `vellum connect import` (a remote
89
+ * pairing). Set alongside `cloud: "paired"`; also backs the re-import /
90
+ * overwrite guard in connect import. */
91
+ paired?: boolean;
80
92
  instanceId?: string;
81
93
  namespace?: string;
82
94
  project?: string;
@@ -0,0 +1,276 @@
1
+ import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ import { GATEWAY_PORT } from "./constants.js";
7
+
8
+ // ── Workspace config helpers (mirrors the pattern in ngrok.ts) ───────────────
9
+
10
+ function getDefaultWorkspaceDir(): string {
11
+ return (
12
+ process.env.VELLUM_WORKSPACE_DIR?.trim() ||
13
+ join(homedir(), ".vellum", "workspace")
14
+ );
15
+ }
16
+
17
+ function getConfigPath(workspaceDir: string): string {
18
+ return join(workspaceDir, "config.json");
19
+ }
20
+
21
+ function loadRawConfig(workspaceDir: string): Record<string, unknown> {
22
+ const configPath = getConfigPath(workspaceDir);
23
+ if (!existsSync(configPath)) return {};
24
+ return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
25
+ string,
26
+ unknown
27
+ >;
28
+ }
29
+
30
+ function saveRawConfig(
31
+ workspaceDir: string,
32
+ config: Record<string, unknown>,
33
+ ): void {
34
+ const configPath = getConfigPath(workspaceDir);
35
+ const dir = dirname(configPath);
36
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
37
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
38
+ }
39
+
40
+ function saveIngressUrl(workspaceDir: string, publicUrl: string): void {
41
+ const config = loadRawConfig(workspaceDir);
42
+ const ingress = (config.ingress ?? {}) as Record<string, unknown>;
43
+ ingress.publicBaseUrl = publicUrl;
44
+ ingress.enabled = true;
45
+ config.ingress = ingress;
46
+ saveRawConfig(workspaceDir, config);
47
+ }
48
+
49
+ function clearIngressUrl(workspaceDir: string): void {
50
+ const config = loadRawConfig(workspaceDir);
51
+ const ingress = (config.ingress ?? {}) as Record<string, unknown>;
52
+ delete ingress.publicBaseUrl;
53
+ config.ingress = ingress;
54
+ saveRawConfig(workspaceDir, config);
55
+ }
56
+
57
+ // ── Cloudflare Tunnel ─────────────────────────────────────────────────────────
58
+
59
+ const CLOUDFLARED_TIMEOUT_MS = 30_000;
60
+
61
+ // Quick-tunnel hostnames follow the pattern <word>-<word>-<word>.trycloudflare.com
62
+ const QUICK_TUNNEL_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
63
+
64
+ /**
65
+ * Check whether cloudflared is installed and on PATH.
66
+ * Returns the version string if found, null otherwise.
67
+ */
68
+ export function getCloudflareTunnelVersion(): string | null {
69
+ try {
70
+ const output = execFileSync("cloudflared", ["version"], {
71
+ encoding: "utf-8",
72
+ timeout: 5_000,
73
+ stdio: ["ignore", "pipe", "ignore"],
74
+ });
75
+ return output.trim();
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Spawn a cloudflared quick-tunnel process forwarding HTTP traffic to
83
+ * `targetPort`. The child process writes its public URL to stderr during
84
+ * startup — use {@link waitForCloudflareTunnelUrl} to extract it.
85
+ */
86
+ export function startCloudflareTunnelProcess(targetPort: number): ChildProcess {
87
+ return spawn(
88
+ "cloudflared",
89
+ ["tunnel", "--url", `http://localhost:${targetPort}`, "--no-autoupdate"],
90
+ // Keep stdio as pipes so we can parse the URL from output.
91
+ { stdio: ["ignore", "pipe", "pipe"] },
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Listen to a running cloudflared process's stdout/stderr and resolve with
97
+ * the public quick-tunnel URL once cloudflared prints it.
98
+ *
99
+ * cloudflared emits a line containing the trycloudflare.com URL during
100
+ * startup — typically within 5–15 seconds on a normal internet connection.
101
+ *
102
+ * Rejects when:
103
+ * - The URL does not appear within `timeoutMs`.
104
+ * - The child process exits before the URL is found.
105
+ */
106
+ export function waitForCloudflareTunnelUrl(
107
+ child: ChildProcess,
108
+ timeoutMs: number = CLOUDFLARED_TIMEOUT_MS,
109
+ ): Promise<string> {
110
+ return new Promise((resolve, reject) => {
111
+ const timer = setTimeout(() => {
112
+ reject(
113
+ new Error(
114
+ `cloudflared tunnel URL did not appear within ${timeoutMs / 1000}s. ` +
115
+ `Ensure cloudflared is working: try running 'cloudflared tunnel --url http://localhost:8080' manually.`,
116
+ ),
117
+ );
118
+ }, timeoutMs);
119
+
120
+ let resolved = false;
121
+
122
+ function scanLine(line: string): void {
123
+ if (resolved) return;
124
+ const match = QUICK_TUNNEL_URL_RE.exec(line);
125
+ if (match) {
126
+ resolved = true;
127
+ clearTimeout(timer);
128
+ resolve(match[0]);
129
+ }
130
+ }
131
+
132
+ // Buffer incomplete lines across chunks
133
+ let stdoutBuf = "";
134
+ let stderrBuf = "";
135
+
136
+ child.stdout?.on("data", (chunk: Buffer) => {
137
+ stdoutBuf += chunk.toString();
138
+ const lines = stdoutBuf.split("\n");
139
+ stdoutBuf = lines.pop() ?? "";
140
+ for (const line of lines) scanLine(line);
141
+ });
142
+
143
+ child.stderr?.on("data", (chunk: Buffer) => {
144
+ stderrBuf += chunk.toString();
145
+ const lines = stderrBuf.split("\n");
146
+ stderrBuf = lines.pop() ?? "";
147
+ for (const line of lines) scanLine(line);
148
+ });
149
+
150
+ child.on("exit", (code) => {
151
+ if (resolved) return;
152
+ clearTimeout(timer);
153
+ reject(
154
+ new Error(
155
+ `cloudflared exited with code ${code ?? "unknown"} before the tunnel URL appeared.`,
156
+ ),
157
+ );
158
+ });
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Run the cloudflared quick-tunnel workflow:
164
+ * 1. Verify cloudflared is installed.
165
+ * 2. Start a quick tunnel pointing at the gateway port.
166
+ * 3. Parse the public URL from cloudflared output.
167
+ * 4. Persist the URL to the workspace config as the ingress base URL.
168
+ * 5. Block until the process exits or the user presses Ctrl+C.
169
+ * 6. Clear the ingress URL from config on exit.
170
+ *
171
+ * No Cloudflare account is required — quick tunnels are free and ephemeral.
172
+ */
173
+ export interface RunCloudflareTunnelOptions {
174
+ /** Gateway port to forward. Defaults to the global GATEWAY_PORT. */
175
+ port?: number;
176
+ /** Workspace directory for config read/write. Defaults to ~/.vellum/workspace. */
177
+ workspaceDir?: string;
178
+ }
179
+
180
+ export async function runCloudflareTunnel(
181
+ opts: RunCloudflareTunnelOptions = {},
182
+ ): Promise<void> {
183
+ const version = getCloudflareTunnelVersion();
184
+ if (!version) {
185
+ console.error("Error: cloudflared is not installed.");
186
+ console.error("");
187
+ console.error("Install cloudflared:");
188
+ console.error(" macOS: brew install cloudflare/cloudflare/cloudflared");
189
+ console.error(" Linux: https://pkg.cloudflare.com/index.html");
190
+ console.error(
191
+ " Windows: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
192
+ );
193
+ console.error("");
194
+ console.error("No Cloudflare account is required for quick tunnels.");
195
+ process.exit(1);
196
+ }
197
+
198
+ console.log(`Using ${version}`);
199
+
200
+ const port = opts.port ?? GATEWAY_PORT;
201
+ const workspaceDir = opts.workspaceDir ?? getDefaultWorkspaceDir();
202
+
203
+ console.log(`Starting cloudflared quick tunnel to localhost:${port}...`);
204
+ console.log("No Cloudflare account required — quick tunnels are free.");
205
+ console.log("");
206
+
207
+ let publicUrl: string | undefined;
208
+ const child = startCloudflareTunnelProcess(port);
209
+
210
+ const cleanup = (): void => {
211
+ if (!child.killed) child.kill("SIGTERM");
212
+ if (publicUrl) {
213
+ console.log("\nClearing ingress URL from config...");
214
+ clearIngressUrl(workspaceDir);
215
+ }
216
+ };
217
+
218
+ process.on("SIGINT", () => {
219
+ cleanup();
220
+ process.exit(0);
221
+ });
222
+ process.on("SIGTERM", () => {
223
+ cleanup();
224
+ process.exit(0);
225
+ });
226
+
227
+ child.on("error", (err: Error) => {
228
+ console.error(`cloudflared process error: ${err.message}`);
229
+ process.exit(1);
230
+ });
231
+
232
+ child.on("exit", (code) => {
233
+ // Always clear the saved ingress URL when the tunnel process ends so
234
+ // webhook integrations don't keep hitting a dead endpoint.
235
+ if (publicUrl !== undefined) {
236
+ clearIngressUrl(workspaceDir);
237
+ }
238
+ if (code !== null && code !== 0) {
239
+ console.error(`\ncloudflared exited with code ${code}.`);
240
+ process.exit(1);
241
+ }
242
+ });
243
+
244
+ // Forward cloudflared output to the console so the user can see startup
245
+ // progress and any authentication errors.
246
+ child.stdout?.on("data", (data: Buffer) => {
247
+ const line = data.toString().trim();
248
+ if (line) console.log(`[cloudflared] ${line}`);
249
+ });
250
+ child.stderr?.on("data", (data: Buffer) => {
251
+ const line = data.toString().trim();
252
+ if (line) console.log(`[cloudflared] ${line}`);
253
+ });
254
+
255
+ try {
256
+ publicUrl = await waitForCloudflareTunnelUrl(child);
257
+ } catch (err) {
258
+ cleanup();
259
+ throw err;
260
+ }
261
+
262
+ console.log("");
263
+ console.log(`Tunnel established: ${publicUrl}`);
264
+ console.log(`Forwarding to: localhost:${port}`);
265
+ console.log("");
266
+
267
+ saveIngressUrl(workspaceDir, publicUrl);
268
+ console.log("Ingress URL saved to config.");
269
+ console.log("");
270
+ console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
271
+
272
+ // Keep running until cloudflared exits (e.g., network error or user Ctrl+C)
273
+ await new Promise<void>((resolve) => {
274
+ child.on("exit", () => resolve());
275
+ });
276
+ }