@tavily/cli 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +322 -2
- package/dist/auth-EQZRRVIV.js +213 -0
- package/dist/chunk-5JXZZSLS.js +30 -0
- package/dist/chunk-AERAIFZU.js +691 -0
- package/dist/chunk-E4N27QWM.js +53 -0
- package/dist/chunk-HQ7QBZ5H.js +8 -0
- package/dist/chunk-IRCF6F3S.js +45 -0
- package/dist/chunk-K76OUMXM.js +43 -0
- package/dist/chunk-KQFC5TMG.js +380 -0
- package/dist/chunk-PZGMUDV2.js +427 -0
- package/dist/chunk-QHWWSCVM.js +85 -0
- package/dist/chunk-SPBOEASO.js +9 -0
- package/dist/chunk-U3B7BNFI.js +40 -0
- package/dist/chunk-ULNXDG4Q.js +147 -0
- package/dist/chunk-V3OJFHHD.js +191 -0
- package/dist/cli.js +132 -0
- package/dist/client-factory-LWJMZVKT.js +338 -0
- package/dist/defaults-344GEHCS.js +204 -0
- package/dist/extract-Q5VLDRON.js +90 -0
- package/dist/init-6PKQ6UES.js +348 -0
- package/dist/repl-IHHLWI3L.js +259 -0
- package/dist/research-PU3YJMQM.js +262 -0
- package/dist/search-XTAH2SDD.js +191 -0
- package/dist/setup-JNKIKMOX.js +52 -0
- package/dist/site-ZG42IEW7.js +151 -0
- package/dist/status-UAAZ7CBV.js +36 -0
- package/dist/update-CWPZZE4P.js +129 -0
- package/package.json +55 -4
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
OAuthError,
|
|
4
|
+
isObject,
|
|
5
|
+
messageOf
|
|
6
|
+
} from "./chunk-5JXZZSLS.js";
|
|
7
|
+
|
|
8
|
+
// src/core/oauth.ts
|
|
9
|
+
import { createHash, randomBytes, randomInt } from "crypto";
|
|
10
|
+
import { createServer } from "http";
|
|
11
|
+
var MCP_ISSUER = "https://mcp.tavily.com/";
|
|
12
|
+
var MCP_RESOURCE = "https://mcp.tavily.com/mcp";
|
|
13
|
+
var OAUTH_METADATA_URL = "https://mcp.tavily.com/.well-known/oauth-authorization-server";
|
|
14
|
+
var PRM_URL = "https://mcp.tavily.com/.well-known/oauth-protected-resource/mcp";
|
|
15
|
+
var DEFAULT_SCOPES = "openid offline_access";
|
|
16
|
+
var CLIENT_NAME = "Tavily CLI";
|
|
17
|
+
var LOGIN_TIMEOUT_SECONDS = 180;
|
|
18
|
+
var HTTP_TIMEOUT_MS = 3e4;
|
|
19
|
+
var EXPIRY_SKEW_SECONDS = 60;
|
|
20
|
+
var FALLBACK_METADATA = {
|
|
21
|
+
authorization_endpoint: "https://mcp.tavily.com/authorize",
|
|
22
|
+
token_endpoint: "https://mcp.tavily.com/token",
|
|
23
|
+
registration_endpoint: "https://mcp.tavily.com/register",
|
|
24
|
+
revocation_endpoint: "https://mcp.tavily.com/revoke"
|
|
25
|
+
};
|
|
26
|
+
var PKCE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
27
|
+
function generatePkce() {
|
|
28
|
+
let verifier = "";
|
|
29
|
+
for (let i = 0; i < 128; i++) verifier += PKCE_ALPHABET[randomInt(PKCE_ALPHABET.length)];
|
|
30
|
+
const challenge = createHash("sha256").update(verifier, "ascii").digest("base64url");
|
|
31
|
+
return { verifier, challenge };
|
|
32
|
+
}
|
|
33
|
+
var nowSeconds = () => Date.now() / 1e3;
|
|
34
|
+
function expiresAtFromNow(expiresIn, now = nowSeconds()) {
|
|
35
|
+
const lifetime = typeof expiresIn === "number" && Number.isInteger(expiresIn) && expiresIn > 0 ? expiresIn : 3600;
|
|
36
|
+
return now + lifetime - EXPIRY_SKEW_SECONDS;
|
|
37
|
+
}
|
|
38
|
+
function tokenIsExpired(expiresAt, now = nowSeconds()) {
|
|
39
|
+
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) return true;
|
|
40
|
+
return now >= expiresAt;
|
|
41
|
+
}
|
|
42
|
+
function validatedHttpsUrl(value, field) {
|
|
43
|
+
const invalid = new OAuthError(
|
|
44
|
+
`OAuth discovery returned an invalid ${field}; expected a secure HTTPS URL.`
|
|
45
|
+
);
|
|
46
|
+
let url;
|
|
47
|
+
try {
|
|
48
|
+
url = new URL(value);
|
|
49
|
+
} catch {
|
|
50
|
+
throw invalid;
|
|
51
|
+
}
|
|
52
|
+
if (url.protocol !== "https:" || !url.host || url.username || url.password || url.hash) {
|
|
53
|
+
throw invalid;
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function timeout() {
|
|
58
|
+
return AbortSignal.timeout(HTTP_TIMEOUT_MS);
|
|
59
|
+
}
|
|
60
|
+
async function send(fetchImpl, url, init) {
|
|
61
|
+
return transport(() => fetchImpl(url, { ...init, signal: timeout(), redirect: "manual" }));
|
|
62
|
+
}
|
|
63
|
+
async function transport(fn) {
|
|
64
|
+
try {
|
|
65
|
+
return await fn();
|
|
66
|
+
} catch (e) {
|
|
67
|
+
const cause = e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
|
|
68
|
+
const message = messageOf(e);
|
|
69
|
+
throw new OAuthError(
|
|
70
|
+
`Could not reach the Tavily OAuth server: ${message}${cause ? `: ${cause}` : ""}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function fetchMetadata(fetchImpl = globalThis.fetch) {
|
|
75
|
+
const data = { ...FALLBACK_METADATA };
|
|
76
|
+
const discoveredJson = async (url) => {
|
|
77
|
+
try {
|
|
78
|
+
const resp = await fetchImpl(url, { signal: timeout(), redirect: "manual" });
|
|
79
|
+
if (!resp.ok) return void 0;
|
|
80
|
+
return await resp.json();
|
|
81
|
+
} catch {
|
|
82
|
+
return void 0;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const discovered = await discoveredJson(OAUTH_METADATA_URL);
|
|
86
|
+
if (isObject(discovered)) {
|
|
87
|
+
if (discovered.issuer !== MCP_ISSUER) {
|
|
88
|
+
throw new OAuthError("OAuth discovery issuer does not match the configured Tavily issuer.");
|
|
89
|
+
}
|
|
90
|
+
for (const field of Object.keys(data)) {
|
|
91
|
+
const value = discovered[field];
|
|
92
|
+
if (typeof value === "string") data[field] = validatedHttpsUrl(value, field);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
let resource = MCP_RESOURCE;
|
|
96
|
+
const prm = await discoveredJson(PRM_URL);
|
|
97
|
+
if (isObject(prm) && typeof prm.resource === "string") {
|
|
98
|
+
const discoveredResource = validatedHttpsUrl(prm.resource, "resource");
|
|
99
|
+
if (discoveredResource !== MCP_RESOURCE) {
|
|
100
|
+
throw new OAuthError(
|
|
101
|
+
"OAuth protected-resource metadata does not match the Tavily MCP resource."
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
resource = discoveredResource;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
authorizationEndpoint: data.authorization_endpoint,
|
|
108
|
+
tokenEndpoint: data.token_endpoint,
|
|
109
|
+
registrationEndpoint: data.registration_endpoint,
|
|
110
|
+
revocationEndpoint: data.revocation_endpoint,
|
|
111
|
+
resource
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
async function jsonObjectResponse(resp, operation) {
|
|
115
|
+
const text = await transport(() => resp.text());
|
|
116
|
+
let data;
|
|
117
|
+
try {
|
|
118
|
+
data = JSON.parse(text);
|
|
119
|
+
} catch {
|
|
120
|
+
throw new OAuthError(`${operation} returned invalid JSON (HTTP ${resp.status}).`);
|
|
121
|
+
}
|
|
122
|
+
if (!isObject(data)) {
|
|
123
|
+
throw new OAuthError(
|
|
124
|
+
`${operation} returned an invalid response (HTTP ${resp.status}; expected a JSON object).`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return data;
|
|
128
|
+
}
|
|
129
|
+
async function httpError(prefix, resp) {
|
|
130
|
+
let detail = (await transport(() => resp.text())).trim().replaceAll("\n", " ");
|
|
131
|
+
if (detail.length > 280) detail = `${detail.slice(0, 277)}...`;
|
|
132
|
+
const status = `${prefix} (HTTP ${resp.status})`;
|
|
133
|
+
return new OAuthError(detail ? `${status}: ${detail}` : `${status}.`);
|
|
134
|
+
}
|
|
135
|
+
async function registerClient(metadata, redirectUri, fetchImpl = globalThis.fetch) {
|
|
136
|
+
const payload = {
|
|
137
|
+
client_name: CLIENT_NAME,
|
|
138
|
+
redirect_uris: [redirectUri],
|
|
139
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
140
|
+
response_types: ["code"],
|
|
141
|
+
token_endpoint_auth_method: "none",
|
|
142
|
+
scope: DEFAULT_SCOPES,
|
|
143
|
+
application_type: "native"
|
|
144
|
+
};
|
|
145
|
+
const resp = await send(fetchImpl, metadata.registrationEndpoint, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "content-type": "application/json" },
|
|
148
|
+
body: JSON.stringify(payload)
|
|
149
|
+
});
|
|
150
|
+
if (!resp.ok) throw await httpError("Client registration failed", resp);
|
|
151
|
+
const data = await jsonObjectResponse(resp, "OAuth client registration");
|
|
152
|
+
const clientId = data.client_id;
|
|
153
|
+
if (typeof clientId !== "string" || !clientId) {
|
|
154
|
+
throw new OAuthError("OAuth registration did not return a client_id.");
|
|
155
|
+
}
|
|
156
|
+
const method = data.token_endpoint_auth_method || "none";
|
|
157
|
+
if (method !== "none" && method !== "client_secret_post" && method !== "client_secret_basic") {
|
|
158
|
+
throw new OAuthError(
|
|
159
|
+
`Authorization server registered the CLI with unsupported token_endpoint_auth_method '${String(method)}'.`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const secret = data.client_secret;
|
|
163
|
+
if (method !== "none" && (typeof secret !== "string" || !secret)) {
|
|
164
|
+
throw new OAuthError(
|
|
165
|
+
`Authorization server registered the CLI for '${method}' but issued no valid client_secret.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
clientId,
|
|
170
|
+
clientSecret: typeof secret === "string" ? secret : null,
|
|
171
|
+
tokenEndpointAuthMethod: method,
|
|
172
|
+
redirectUri
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function buildAuthorizeUrl(metadata, client, { state, codeChallenge }) {
|
|
176
|
+
const params = new URLSearchParams({
|
|
177
|
+
response_type: "code",
|
|
178
|
+
client_id: client.clientId,
|
|
179
|
+
redirect_uri: client.redirectUri,
|
|
180
|
+
scope: DEFAULT_SCOPES,
|
|
181
|
+
state,
|
|
182
|
+
code_challenge: codeChallenge,
|
|
183
|
+
code_challenge_method: "S256",
|
|
184
|
+
resource: metadata.resource
|
|
185
|
+
});
|
|
186
|
+
return `${metadata.authorizationEndpoint}?${params}`;
|
|
187
|
+
}
|
|
188
|
+
function tokenAuth(client) {
|
|
189
|
+
if (client.tokenEndpointAuthMethod === "client_secret_post" && client.clientSecret) {
|
|
190
|
+
return {
|
|
191
|
+
extra: { client_id: client.clientId, client_secret: client.clientSecret },
|
|
192
|
+
basic: null
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (client.tokenEndpointAuthMethod === "client_secret_basic" && client.clientSecret) {
|
|
196
|
+
const creds = Buffer.from(`${client.clientId}:${client.clientSecret}`).toString("base64");
|
|
197
|
+
return { extra: {}, basic: `Basic ${creds}` };
|
|
198
|
+
}
|
|
199
|
+
return { extra: { client_id: client.clientId }, basic: null };
|
|
200
|
+
}
|
|
201
|
+
function formRequest(body, basic) {
|
|
202
|
+
const headers = {
|
|
203
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
204
|
+
};
|
|
205
|
+
if (basic) headers.authorization = basic;
|
|
206
|
+
return { method: "POST", headers, body: new URLSearchParams(body).toString() };
|
|
207
|
+
}
|
|
208
|
+
async function requestTokens(tokenEndpoint, body, basic, fetchImpl) {
|
|
209
|
+
const resp = await send(fetchImpl, tokenEndpoint, formRequest(body, basic));
|
|
210
|
+
if (!resp.ok) throw await httpError("Token request failed", resp);
|
|
211
|
+
const data = await jsonObjectResponse(resp, "OAuth token endpoint");
|
|
212
|
+
const access = data.access_token;
|
|
213
|
+
if (typeof access !== "string" || !access) {
|
|
214
|
+
throw new OAuthError("OAuth token response did not include a valid access_token.");
|
|
215
|
+
}
|
|
216
|
+
const refresh = data.refresh_token;
|
|
217
|
+
const tokenType = data.token_type;
|
|
218
|
+
return {
|
|
219
|
+
accessToken: access,
|
|
220
|
+
refreshToken: typeof refresh === "string" ? refresh : null,
|
|
221
|
+
expiresAt: expiresAtFromNow(data.expires_in),
|
|
222
|
+
tokenType: typeof tokenType === "string" && tokenType ? tokenType : "Bearer"
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
async function exchangeCode(metadata, client, { code, codeVerifier }, fetchImpl = globalThis.fetch) {
|
|
226
|
+
const { extra, basic } = tokenAuth(client);
|
|
227
|
+
const body = {
|
|
228
|
+
grant_type: "authorization_code",
|
|
229
|
+
code,
|
|
230
|
+
redirect_uri: client.redirectUri,
|
|
231
|
+
code_verifier: codeVerifier,
|
|
232
|
+
resource: metadata.resource,
|
|
233
|
+
...extra
|
|
234
|
+
};
|
|
235
|
+
return requestTokens(metadata.tokenEndpoint, body, basic, fetchImpl);
|
|
236
|
+
}
|
|
237
|
+
async function refreshTokens(metadata, client, refreshToken, fetchImpl = globalThis.fetch) {
|
|
238
|
+
const { extra, basic } = tokenAuth(client);
|
|
239
|
+
const body = {
|
|
240
|
+
grant_type: "refresh_token",
|
|
241
|
+
refresh_token: refreshToken,
|
|
242
|
+
resource: metadata.resource,
|
|
243
|
+
...extra
|
|
244
|
+
};
|
|
245
|
+
const tokens = await requestTokens(metadata.tokenEndpoint, body, basic, fetchImpl);
|
|
246
|
+
return tokens.refreshToken === null ? { ...tokens, refreshToken } : tokens;
|
|
247
|
+
}
|
|
248
|
+
async function revokeToken(metadata, client, token, { tokenTypeHint }, fetchImpl = globalThis.fetch) {
|
|
249
|
+
if (!metadata.revocationEndpoint) {
|
|
250
|
+
throw new OAuthError("OAuth server does not advertise a token revocation endpoint.");
|
|
251
|
+
}
|
|
252
|
+
const { extra, basic } = tokenAuth(client);
|
|
253
|
+
const body = { token, token_type_hint: tokenTypeHint, ...extra };
|
|
254
|
+
let resp = await send(fetchImpl, metadata.revocationEndpoint, formRequest(body, basic));
|
|
255
|
+
if (resp.status === 400 && client.tokenEndpointAuthMethod === "none") {
|
|
256
|
+
resp = await send(
|
|
257
|
+
fetchImpl,
|
|
258
|
+
metadata.revocationEndpoint,
|
|
259
|
+
formRequest({ ...body, client_secret: "" }, basic)
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (!resp.ok) throw await httpError("Token revocation failed", resp);
|
|
263
|
+
}
|
|
264
|
+
function looksHeadless(env, platform) {
|
|
265
|
+
if (env.SSH_CONNECTION || env.SSH_TTY) return true;
|
|
266
|
+
return platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
267
|
+
}
|
|
268
|
+
function callbackHtml({ success }) {
|
|
269
|
+
const message = success ? "You're signed in. You can close this tab and return to the terminal." : "Sign-in didn't complete. Return to the terminal for details.";
|
|
270
|
+
const color = success ? "#9BC0AE" : "#FAA2FB";
|
|
271
|
+
return `<!DOCTYPE html>
|
|
272
|
+
<html lang="en"><head><meta charset="utf-8"><title>Tavily CLI</title></head>
|
|
273
|
+
<body style="font-family: system-ui, sans-serif; background:#111; color:#eee;
|
|
274
|
+
display:flex; min-height:100vh; align-items:center; justify-content:center;">
|
|
275
|
+
<div style="max-width:32rem; text-align:center;">
|
|
276
|
+
<p style="color:${color}; font-size:1.25rem; font-weight:600;">tavily</p>
|
|
277
|
+
<p>${message}</p>
|
|
278
|
+
</div>
|
|
279
|
+
</body></html>`;
|
|
280
|
+
}
|
|
281
|
+
async function runBrowserLogin(opts) {
|
|
282
|
+
const { timeoutSeconds = LOGIN_TIMEOUT_SECONDS, fetchImpl } = opts;
|
|
283
|
+
const metadata = await fetchMetadata(fetchImpl);
|
|
284
|
+
let resolveCallback = () => {
|
|
285
|
+
};
|
|
286
|
+
const callback = new Promise((r) => {
|
|
287
|
+
resolveCallback = r;
|
|
288
|
+
});
|
|
289
|
+
let handled = false;
|
|
290
|
+
const server = createServer((req, res) => {
|
|
291
|
+
let url;
|
|
292
|
+
try {
|
|
293
|
+
url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
294
|
+
} catch {
|
|
295
|
+
}
|
|
296
|
+
if (!url || req.method !== "GET" || url.pathname !== "/callback" || handled) {
|
|
297
|
+
res.writeHead(404).end();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
handled = true;
|
|
301
|
+
const q = url.searchParams;
|
|
302
|
+
const params = {
|
|
303
|
+
code: q.get("code"),
|
|
304
|
+
state: q.get("state"),
|
|
305
|
+
error: q.get("error"),
|
|
306
|
+
errorDescription: q.get("error_description")
|
|
307
|
+
};
|
|
308
|
+
const ok = Boolean(params.code) && !params.error;
|
|
309
|
+
const body = Buffer.from(callbackHtml({ success: ok }), "utf8");
|
|
310
|
+
res.writeHead(ok ? 200 : 400, {
|
|
311
|
+
"content-type": "text/html; charset=utf-8",
|
|
312
|
+
"content-length": body.length
|
|
313
|
+
});
|
|
314
|
+
res.end(body);
|
|
315
|
+
resolveCallback(params);
|
|
316
|
+
});
|
|
317
|
+
let timer;
|
|
318
|
+
try {
|
|
319
|
+
await new Promise((resolve, reject) => {
|
|
320
|
+
server.once("error", reject);
|
|
321
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
322
|
+
});
|
|
323
|
+
const port = server.address().port;
|
|
324
|
+
const client = await registerClient(metadata, `http://127.0.0.1:${port}/callback`, fetchImpl);
|
|
325
|
+
const { verifier, challenge } = generatePkce();
|
|
326
|
+
const state = randomBytes(32).toString("base64url");
|
|
327
|
+
const authorizeUrl = buildAuthorizeUrl(metadata, client, { state, codeChallenge: challenge });
|
|
328
|
+
opts.onStatus?.(authorizeUrl);
|
|
329
|
+
if (opts.openBrowser) opts.openImpl(authorizeUrl);
|
|
330
|
+
const timeout2 = new Promise((_, reject) => {
|
|
331
|
+
timer = setTimeout(
|
|
332
|
+
() => reject(
|
|
333
|
+
new OAuthError(
|
|
334
|
+
`Timed out after ${timeoutSeconds}s waiting for browser authorization. Open the login URL printed above, or use: tvly login --api-key tvly-YOUR_KEY`
|
|
335
|
+
)
|
|
336
|
+
),
|
|
337
|
+
timeoutSeconds * 1e3
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
const result = await Promise.race([callback, timeout2]);
|
|
341
|
+
if (result.error) {
|
|
342
|
+
throw new OAuthError(`Authorization was denied: ${result.errorDescription || result.error}`);
|
|
343
|
+
}
|
|
344
|
+
if (result.state !== state) {
|
|
345
|
+
throw new OAuthError("OAuth state mismatch; aborting to prevent CSRF.");
|
|
346
|
+
}
|
|
347
|
+
if (!result.code) throw new OAuthError("Authorization callback did not include a code.");
|
|
348
|
+
const tokens = await exchangeCode(
|
|
349
|
+
metadata,
|
|
350
|
+
client,
|
|
351
|
+
{ code: result.code, codeVerifier: verifier },
|
|
352
|
+
fetchImpl
|
|
353
|
+
);
|
|
354
|
+
return { tokens, client };
|
|
355
|
+
} finally {
|
|
356
|
+
clearTimeout(timer);
|
|
357
|
+
server.close();
|
|
358
|
+
server.closeAllConnections();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/core/config.ts
|
|
363
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
364
|
+
import { readdirSync, readFileSync, rmSync } from "fs";
|
|
365
|
+
import fs from "fs/promises";
|
|
366
|
+
import path from "path";
|
|
367
|
+
import { isDeepStrictEqual } from "util";
|
|
368
|
+
import lockfile from "proper-lockfile";
|
|
369
|
+
function configPaths(home) {
|
|
370
|
+
const dir = path.join(home, ".tavily");
|
|
371
|
+
return {
|
|
372
|
+
dir,
|
|
373
|
+
file: path.join(dir, "config.json"),
|
|
374
|
+
mcpAuthDir: path.join(home, ".mcp-auth")
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function nonEmptyString(value) {
|
|
378
|
+
return typeof value === "string" && value !== "";
|
|
379
|
+
}
|
|
380
|
+
function readConfig(host) {
|
|
381
|
+
try {
|
|
382
|
+
const data = JSON.parse(readFileSync(configPaths(host.home).file, "utf8"));
|
|
383
|
+
return isObject(data) ? data : {};
|
|
384
|
+
} catch {
|
|
385
|
+
return {};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async function chmod(target, mode, platform) {
|
|
389
|
+
try {
|
|
390
|
+
await fs.chmod(target, mode);
|
|
391
|
+
} catch (e) {
|
|
392
|
+
if (platform !== "win32") throw e;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async function ensureConfigDir(dir, platform) {
|
|
396
|
+
await fs.mkdir(dir, { recursive: true, mode: 448 });
|
|
397
|
+
await chmod(dir, 448, platform);
|
|
398
|
+
}
|
|
399
|
+
async function writeConfigUnlocked(config, host) {
|
|
400
|
+
const { dir, file } = configPaths(host.home);
|
|
401
|
+
await ensureConfigDir(dir, host.platform);
|
|
402
|
+
if (Object.keys(config).length === 0) {
|
|
403
|
+
await fs.rm(file, { force: true });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const temp = path.join(dir, `.config.json.${randomBytes2(6).toString("hex")}.tmp`);
|
|
407
|
+
try {
|
|
408
|
+
const handle = await fs.open(temp, "wx", 384);
|
|
409
|
+
try {
|
|
410
|
+
await handle.writeFile(`${JSON.stringify(config, null, 2)}
|
|
411
|
+
`, "utf8");
|
|
412
|
+
await handle.sync();
|
|
413
|
+
} finally {
|
|
414
|
+
await handle.close();
|
|
415
|
+
}
|
|
416
|
+
await chmod(temp, 384, host.platform);
|
|
417
|
+
await fs.rename(temp, file);
|
|
418
|
+
} finally {
|
|
419
|
+
await fs.rm(temp, { force: true });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
async function withConfigLock(fn, host) {
|
|
423
|
+
const { dir, file } = configPaths(host.home);
|
|
424
|
+
await ensureConfigDir(dir, host.platform);
|
|
425
|
+
const release = await lockfile.lock(file, {
|
|
426
|
+
lockfilePath: path.join(dir, ".config.lock"),
|
|
427
|
+
stale: 3e4,
|
|
428
|
+
// Backoff starts at 20 ms and is capped so a waiter polls at least once per second.
|
|
429
|
+
retries: { retries: 50, minTimeout: 20, maxTimeout: 1e3 },
|
|
430
|
+
realpath: false,
|
|
431
|
+
// The write is an atomic rename, so a lock that goes stale mid-transaction (e.g. the
|
|
432
|
+
// laptop sleeps) can at worst lose one update; that beats crashing the CLI, which is
|
|
433
|
+
// what proper-lockfile's default handler does.
|
|
434
|
+
onCompromised: () => {
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
try {
|
|
438
|
+
const { config, result } = await fn(readConfig(host));
|
|
439
|
+
if (config !== void 0) await writeConfigUnlocked(config, host);
|
|
440
|
+
return result;
|
|
441
|
+
} finally {
|
|
442
|
+
await release().catch((e) => {
|
|
443
|
+
if (e.code !== "ERELEASED") throw e;
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function oauthSessionData(session) {
|
|
448
|
+
return {
|
|
449
|
+
access_token: session.tokens.accessToken,
|
|
450
|
+
refresh_token: session.tokens.refreshToken,
|
|
451
|
+
expires_at: session.tokens.expiresAt,
|
|
452
|
+
token_type: session.tokens.tokenType,
|
|
453
|
+
client_id: session.client.clientId,
|
|
454
|
+
client_secret: session.client.clientSecret,
|
|
455
|
+
token_endpoint_auth_method: session.client.tokenEndpointAuthMethod,
|
|
456
|
+
redirect_uri: session.client.redirectUri
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function withoutKeys(config, ...keys) {
|
|
460
|
+
const copy = { ...config };
|
|
461
|
+
for (const key of keys) delete copy[key];
|
|
462
|
+
return copy;
|
|
463
|
+
}
|
|
464
|
+
async function saveApiKey(apiKey, host) {
|
|
465
|
+
await withConfigLock(async (config) => {
|
|
466
|
+
if (isObject(config.oauth)) {
|
|
467
|
+
try {
|
|
468
|
+
await revokeStoredOauth(config.oauth, host);
|
|
469
|
+
} catch {
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return { config: { ...withoutKeys(config, "oauth"), api_key: apiKey }, result: void 0 };
|
|
473
|
+
}, host);
|
|
474
|
+
}
|
|
475
|
+
async function saveOauthSession(session, host) {
|
|
476
|
+
const replacement = oauthSessionData(session);
|
|
477
|
+
await withConfigLock(async (config) => {
|
|
478
|
+
const previous = config.oauth;
|
|
479
|
+
if (isObject(previous) && !isDeepStrictEqual(previous, replacement)) {
|
|
480
|
+
try {
|
|
481
|
+
await revokeStoredOauth(previous, host);
|
|
482
|
+
} catch (previousError) {
|
|
483
|
+
if (!(previousError instanceof OAuthError)) throw previousError;
|
|
484
|
+
try {
|
|
485
|
+
await revokeStoredOauth(replacement, host);
|
|
486
|
+
} catch (cleanupError) {
|
|
487
|
+
if (!(cleanupError instanceof OAuthError)) throw cleanupError;
|
|
488
|
+
throw new OAuthError(
|
|
489
|
+
`Could not replace the previous OAuth session: ${previousError.message} The new OAuth session also could not be revoked: ${cleanupError.message}`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
throw new OAuthError(
|
|
493
|
+
`Could not replace the previous OAuth session: ${previousError.message}`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return { config: { ...withoutKeys(config, "api_key"), oauth: replacement }, result: void 0 };
|
|
498
|
+
}, host);
|
|
499
|
+
}
|
|
500
|
+
function hasStoredOauth(host) {
|
|
501
|
+
const data = readConfig(host).oauth;
|
|
502
|
+
return isObject(data) && typeof data.access_token === "string";
|
|
503
|
+
}
|
|
504
|
+
function getHumanId(host) {
|
|
505
|
+
const value = host.env.TAVILY_HUMAN_ID;
|
|
506
|
+
if (value) return value;
|
|
507
|
+
const configured = readConfig(host).human_id;
|
|
508
|
+
return typeof configured === "string" ? configured : null;
|
|
509
|
+
}
|
|
510
|
+
function getApiBaseUrl(host) {
|
|
511
|
+
const stripSlashes = (url) => url.replace(/\/+$/, "");
|
|
512
|
+
const value = host.env.TAVILY_API_BASE_URL;
|
|
513
|
+
if (value) return stripSlashes(value);
|
|
514
|
+
const configured = readConfig(host).api_base_url;
|
|
515
|
+
return nonEmptyString(configured) ? stripSlashes(configured) : null;
|
|
516
|
+
}
|
|
517
|
+
async function clearCredentials(host) {
|
|
518
|
+
const result = await withConfigLock(async (config) => {
|
|
519
|
+
const outcome = { serverRevoked: null };
|
|
520
|
+
if (isObject(config.oauth)) {
|
|
521
|
+
try {
|
|
522
|
+
if (await revokeStoredOauth(config.oauth, host)) outcome.serverRevoked = true;
|
|
523
|
+
} catch (e) {
|
|
524
|
+
outcome.serverRevoked = false;
|
|
525
|
+
outcome.revocationError = e instanceof Error && (e.message || e.name) || String(e);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return { config: withoutKeys(config, "api_key", "oauth"), result: outcome };
|
|
529
|
+
}, host);
|
|
530
|
+
clearMcpTokens(host);
|
|
531
|
+
return result;
|
|
532
|
+
}
|
|
533
|
+
function decodeJwtPayload(token) {
|
|
534
|
+
const parts = token.split(".");
|
|
535
|
+
if (parts.length !== 3 || parts[1] === void 0) return null;
|
|
536
|
+
try {
|
|
537
|
+
const payload = JSON.parse(
|
|
538
|
+
new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(parts[1], "base64url"))
|
|
539
|
+
);
|
|
540
|
+
return isObject(payload) ? payload : null;
|
|
541
|
+
} catch {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function isTavilyToken(token) {
|
|
546
|
+
return decodeJwtPayload(token)?.iss === MCP_ISSUER;
|
|
547
|
+
}
|
|
548
|
+
function isValidTavilyToken(token) {
|
|
549
|
+
const payload = decodeJwtPayload(token);
|
|
550
|
+
if (payload?.iss !== MCP_ISSUER) return false;
|
|
551
|
+
const exp = payload.exp;
|
|
552
|
+
if (exp === void 0 || exp === null) return true;
|
|
553
|
+
return typeof exp === "number" && Date.now() / 1e3 < exp;
|
|
554
|
+
}
|
|
555
|
+
function mcpTokenFiles(host) {
|
|
556
|
+
const root = configPaths(host.home).mcpAuthDir;
|
|
557
|
+
let names;
|
|
558
|
+
try {
|
|
559
|
+
names = readdirSync(root, { recursive: true, encoding: "utf8" });
|
|
560
|
+
} catch {
|
|
561
|
+
return [];
|
|
562
|
+
}
|
|
563
|
+
const found = [];
|
|
564
|
+
for (const name of names) {
|
|
565
|
+
if (!path.basename(name).endsWith("_tokens.json")) continue;
|
|
566
|
+
const file = path.join(root, name);
|
|
567
|
+
try {
|
|
568
|
+
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
569
|
+
if (isObject(data)) found.push({ file, token: data.access_token });
|
|
570
|
+
} catch {
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return found;
|
|
574
|
+
}
|
|
575
|
+
function getMcpToken(host) {
|
|
576
|
+
for (const { token } of mcpTokenFiles(host)) {
|
|
577
|
+
if (nonEmptyString(token) && isValidTavilyToken(token)) return token;
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
function clearMcpTokens(host) {
|
|
582
|
+
for (const { file, token } of mcpTokenFiles(host)) {
|
|
583
|
+
if (!nonEmptyString(token) || !isTavilyToken(token)) continue;
|
|
584
|
+
try {
|
|
585
|
+
rmSync(file);
|
|
586
|
+
} catch {
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function oauthClientFromConfig(data) {
|
|
591
|
+
const clientId = data.client_id;
|
|
592
|
+
if (!nonEmptyString(clientId)) return null;
|
|
593
|
+
const { client_secret: secret, token_endpoint_auth_method: method, redirect_uri: uri } = data;
|
|
594
|
+
return {
|
|
595
|
+
clientId,
|
|
596
|
+
clientSecret: typeof secret === "string" ? secret : null,
|
|
597
|
+
tokenEndpointAuthMethod: nonEmptyString(method) ? method : "none",
|
|
598
|
+
redirectUri: nonEmptyString(uri) ? uri : "http://127.0.0.1/callback"
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
async function revokeStoredOauth(data, host) {
|
|
602
|
+
const present = ["refresh_token", "access_token"].flatMap((key) => {
|
|
603
|
+
const token = data[key];
|
|
604
|
+
return nonEmptyString(token) ? [{ key, token }] : [];
|
|
605
|
+
});
|
|
606
|
+
if (present.length === 0) return false;
|
|
607
|
+
const client = oauthClientFromConfig(data);
|
|
608
|
+
if (client === null) {
|
|
609
|
+
throw new OAuthError(
|
|
610
|
+
"Stored OAuth client metadata is incomplete; server revocation was not attempted."
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const metadata = await fetchMetadata(host.fetch);
|
|
614
|
+
const errors = [];
|
|
615
|
+
for (const { key, token } of present) {
|
|
616
|
+
try {
|
|
617
|
+
await revokeToken(metadata, client, token, { tokenTypeHint: key }, host.fetch);
|
|
618
|
+
} catch (e) {
|
|
619
|
+
if (!(e instanceof OAuthError)) throw e;
|
|
620
|
+
errors.push(`${key}: ${e.message}`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (errors.length > 0) throw new OAuthError(errors.join("; "));
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
async function oauthAccessToken(config, host) {
|
|
627
|
+
const data = config.oauth;
|
|
628
|
+
if (!isObject(data)) return null;
|
|
629
|
+
const access = data.access_token;
|
|
630
|
+
if (!nonEmptyString(access)) return null;
|
|
631
|
+
if (!tokenIsExpired(data.expires_at)) return access;
|
|
632
|
+
const refresh = data.refresh_token;
|
|
633
|
+
const client = oauthClientFromConfig(data);
|
|
634
|
+
if (!nonEmptyString(refresh) || client === null) return null;
|
|
635
|
+
try {
|
|
636
|
+
const tokens = await refreshTokens(
|
|
637
|
+
await fetchMetadata(host.fetch),
|
|
638
|
+
client,
|
|
639
|
+
refresh,
|
|
640
|
+
host.fetch
|
|
641
|
+
);
|
|
642
|
+
const oauth = oauthSessionData({ tokens, client });
|
|
643
|
+
await writeConfigUnlocked({ ...withoutKeys(config, "api_key"), oauth }, host);
|
|
644
|
+
return tokens.accessToken;
|
|
645
|
+
} catch (e) {
|
|
646
|
+
throw new OAuthError(
|
|
647
|
+
`Could not refresh the stored Tavily OAuth session. The stored credentials were not removed; retry the command, and run \`tvly login\` only if it continues. Details: ${messageOf(e)}`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async function getApiKey(host) {
|
|
652
|
+
const envKey = host.env.TAVILY_API_KEY;
|
|
653
|
+
if (envKey) return envKey;
|
|
654
|
+
const config = readConfig(host);
|
|
655
|
+
if (nonEmptyString(config.api_key)) return config.api_key;
|
|
656
|
+
const oauth = config.oauth;
|
|
657
|
+
const needsRefresh = isObject(oauth) && nonEmptyString(oauth.access_token) && tokenIsExpired(oauth.expires_at);
|
|
658
|
+
const stored = needsRefresh ? await withConfigLock(async (fresh) => {
|
|
659
|
+
if (nonEmptyString(fresh.api_key)) return { result: fresh.api_key };
|
|
660
|
+
return { result: await oauthAccessToken(fresh, host) };
|
|
661
|
+
}, host) : await oauthAccessToken(config, host);
|
|
662
|
+
return stored ?? getMcpToken(host);
|
|
663
|
+
}
|
|
664
|
+
function isOauthToken(key) {
|
|
665
|
+
return !key.startsWith("tvly-") && decodeJwtPayload(key) !== null;
|
|
666
|
+
}
|
|
667
|
+
function authMethod(key, host) {
|
|
668
|
+
if (host.env.TAVILY_API_KEY) return "env";
|
|
669
|
+
if (isOauthToken(key)) return hasStoredOauth(host) ? "oauth" : "oauth_legacy";
|
|
670
|
+
return "api_key";
|
|
671
|
+
}
|
|
672
|
+
function authSource(key, env) {
|
|
673
|
+
if (env.TAVILY_API_KEY) return "TAVILY_API_KEY";
|
|
674
|
+
if (isOauthToken(key)) return "OAuth (tvly login)";
|
|
675
|
+
return "API key";
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export {
|
|
679
|
+
looksHeadless,
|
|
680
|
+
runBrowserLogin,
|
|
681
|
+
configPaths,
|
|
682
|
+
saveApiKey,
|
|
683
|
+
saveOauthSession,
|
|
684
|
+
getHumanId,
|
|
685
|
+
getApiBaseUrl,
|
|
686
|
+
clearCredentials,
|
|
687
|
+
getApiKey,
|
|
688
|
+
isOauthToken,
|
|
689
|
+
authMethod,
|
|
690
|
+
authSource
|
|
691
|
+
};
|