@vellumai/cli 0.8.7 → 0.8.8-dev.202606060043.60454ad
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@vellumai/local-mode/package.json +2 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +15 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +15 -8
- package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +42 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +22 -4
- package/node_modules/@vellumai/local-mode/src/index.ts +26 -4
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -7
- package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-client-refresh.test.ts +182 -0
- package/src/__tests__/clean.test.ts +179 -0
- package/src/__tests__/client-token.test.ts +87 -0
- package/src/__tests__/client-tui-refresh.test.ts +170 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
- package/src/__tests__/connect-import.test.ts +317 -0
- package/src/__tests__/devices.test.ts +272 -0
- package/src/__tests__/guardian-token.test.ts +126 -2
- package/src/__tests__/pair.test.ts +271 -0
- package/src/__tests__/paired-lifecycle.test.ts +116 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
- package/src/__tests__/unpair.test.ts +163 -0
- package/src/commands/client.ts +115 -26
- package/src/commands/connect/import.ts +217 -0
- package/src/commands/connect.ts +31 -0
- package/src/commands/devices.ts +247 -0
- package/src/commands/pair.ts +222 -0
- package/src/commands/ps.ts +16 -0
- package/src/commands/retire.ts +20 -47
- package/src/commands/sleep.ts +7 -0
- package/src/commands/tunnel.ts +46 -2
- package/src/commands/unpair.ts +118 -0
- package/src/commands/wake.ts +7 -0
- package/src/components/DefaultMainScreen.tsx +84 -13
- package/src/index.ts +16 -0
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +12 -0
- package/src/lib/cloudflare-tunnel.ts +276 -0
- package/src/lib/confirm-action.ts +57 -0
- package/src/lib/docker.ts +25 -1
- package/src/lib/environments/resolve.ts +9 -30
- package/src/lib/guardian-token.ts +120 -4
- 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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
217
|
-
|
|
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
|
+
}
|