@yugabytedb/meko-mcp 2.3.0 → 2.5.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/README.md +72 -33
- package/assets/hooks-handlers/lib/capture-cleanup-cli.js +735 -74
- package/assets/hooks-handlers/lib/capture-notices.js +94 -1
- package/assets/hooks-handlers/lib/capture-state.js +111 -0
- package/assets/hooks-handlers/lib/capture.js +129 -4
- package/assets/hooks-handlers/lib/checkpoint-timer.js +394 -8
- package/assets/hooks-handlers/lib/opencode-handler.js +29 -6
- package/assets/scripts/meko-statusline.sh +17 -1
- package/assets/skills/meko-mcp-tools/SKILL.md +5 -5
- package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +193 -9
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -3
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +11 -6
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +4 -4
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +16 -11
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +67 -8
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +3 -1
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +7 -7
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +4 -4
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +193 -9
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +10 -5
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +4 -4
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +16 -11
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +67 -8
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -1
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +91 -12
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +39 -7
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +8 -6
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +13 -1
- package/bin/create-meko-setup.mjs +117 -24
- package/lib/autofix.mjs +100 -0
- package/lib/browser-login.mjs +495 -0
- package/lib/clients/opencode.mjs +6 -1
- package/lib/migrate/datapack.mjs +254 -16
- package/lib/migrate/mcp-client.mjs +34 -0
- package/lib/migrate/orchestrator.mjs +1 -1
- package/lib/migrate.mjs +1 -1
- package/lib/validate.mjs +111 -22
- package/package.json +5 -5
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser sign-in for Cloud Meko: OAuth once, then trade the session for an
|
|
3
|
+
* API key.
|
|
4
|
+
*
|
|
5
|
+
* The flow is the MCP authorization spec, ported from
|
|
6
|
+
* yugabyte/meko-cli lib/oauth.mjs:
|
|
7
|
+
*
|
|
8
|
+
* 1. Probe the MCP endpoint unauthenticated and read WWW-Authenticate.
|
|
9
|
+
* 2. Discover protected-resource and authorization-server metadata.
|
|
10
|
+
* 3. Register a public client (DCR) with a loopback redirect_uri.
|
|
11
|
+
* 4. Open the browser on /authorize with PKCE (S256) and wait for the
|
|
12
|
+
* callback on 127.0.0.1.
|
|
13
|
+
* 5. Exchange the code for an access token.
|
|
14
|
+
* 6. POST it to <server>/api-keys, which creates an `mko_tkn_` key for the
|
|
15
|
+
* signed-in user.
|
|
16
|
+
*
|
|
17
|
+
* Only the API key leaves this module. The OAuth tokens are discarded: the
|
|
18
|
+
* client configs and capture hooks all authenticate with the key, so nothing
|
|
19
|
+
* here needs refreshing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
23
|
+
import http from "node:http";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
|
|
27
|
+
import { MEKO_PRODUCTION_CONSOLE_URL } from "./clients/common.mjs";
|
|
28
|
+
|
|
29
|
+
const PROTOCOL_VERSION = "2025-03-26";
|
|
30
|
+
const USER_AGENT = "meko-installer/1.0 (+https://github.com/yugabyte/meko-mcp-server)";
|
|
31
|
+
export const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
32
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
33
|
+
const MAX_KEY_NAME_LEN = 100;
|
|
34
|
+
|
|
35
|
+
export class BrowserLoginError extends Error {
|
|
36
|
+
constructor(message, { code = "login_failed", status = null } = {}) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "BrowserLoginError";
|
|
39
|
+
this.code = code;
|
|
40
|
+
this.status = status;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Sign in through the browser and return a freshly created API key.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} params
|
|
48
|
+
* @param {string} params.url MCP endpoint, e.g. https://mcp.mekodata.ai/mcp
|
|
49
|
+
* @param {string} [params.keyName] Label for the new key (default: installer + hostname + time)
|
|
50
|
+
* @param {boolean} [params.noBrowser] Print the URL instead of opening a browser
|
|
51
|
+
* @param {number} [params.loginTimeoutMs]
|
|
52
|
+
* @param {(msg: string) => void} [params.notify]
|
|
53
|
+
* @param {typeof fetch} [params.fetchImpl]
|
|
54
|
+
* @param {(url: string) => Promise<boolean>} [params.openBrowser]
|
|
55
|
+
* @returns {Promise<{apiKey: string, id: string|null, name: string, expiresAt: string|null}>}
|
|
56
|
+
*/
|
|
57
|
+
export async function loginAndCreateApiKey({
|
|
58
|
+
url,
|
|
59
|
+
keyName = defaultKeyName(),
|
|
60
|
+
noBrowser = false,
|
|
61
|
+
loginTimeoutMs = DEFAULT_LOGIN_TIMEOUT_MS,
|
|
62
|
+
notify = () => {},
|
|
63
|
+
fetchImpl = globalThis.fetch,
|
|
64
|
+
openBrowser = openDefaultBrowser,
|
|
65
|
+
} = {}) {
|
|
66
|
+
const accessToken = await loginOAuth({
|
|
67
|
+
url,
|
|
68
|
+
noBrowser,
|
|
69
|
+
loginTimeoutMs,
|
|
70
|
+
notify,
|
|
71
|
+
fetchImpl,
|
|
72
|
+
openBrowser,
|
|
73
|
+
});
|
|
74
|
+
return createApiKey({ url, accessToken, keyName, fetchImpl });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function loginOAuth({
|
|
78
|
+
url,
|
|
79
|
+
noBrowser = false,
|
|
80
|
+
loginTimeoutMs = DEFAULT_LOGIN_TIMEOUT_MS,
|
|
81
|
+
notify = () => {},
|
|
82
|
+
fetchImpl = globalThis.fetch,
|
|
83
|
+
openBrowser = openDefaultBrowser,
|
|
84
|
+
}) {
|
|
85
|
+
const callback = await createCallbackServer();
|
|
86
|
+
try {
|
|
87
|
+
const discovery = await discoverOAuth(url, fetchImpl);
|
|
88
|
+
const client = await registerClient(discovery, callback.redirectUri, fetchImpl);
|
|
89
|
+
const state = base64url(randomBytes(32));
|
|
90
|
+
const verifier = base64url(randomBytes(48));
|
|
91
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
92
|
+
const authorizationUrl = buildAuthorizationUrl({
|
|
93
|
+
discovery,
|
|
94
|
+
client,
|
|
95
|
+
redirectUri: callback.redirectUri,
|
|
96
|
+
state,
|
|
97
|
+
challenge,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (noBrowser) {
|
|
101
|
+
notify(`Open this URL in a browser to sign in:\n ${authorizationUrl}`);
|
|
102
|
+
} else {
|
|
103
|
+
const opened = await openBrowser(authorizationUrl);
|
|
104
|
+
notify(
|
|
105
|
+
opened
|
|
106
|
+
? `Opened your browser to sign in. If it didn't open, use this URL:\n ${authorizationUrl}`
|
|
107
|
+
: `Could not open a browser. Open this URL to sign in:\n ${authorizationUrl}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const returned = await callback.wait(loginTimeoutMs);
|
|
112
|
+
if (returned.error) {
|
|
113
|
+
throw new BrowserLoginError(
|
|
114
|
+
`Sign-in failed: ${returned.error_description ?? returned.error}`,
|
|
115
|
+
{ code: "authorization_denied" },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!safeEqual(returned.state, state)) {
|
|
119
|
+
throw new BrowserLoginError("Sign-in callback state did not match. Run the installer again.");
|
|
120
|
+
}
|
|
121
|
+
if (!returned.code) {
|
|
122
|
+
throw new BrowserLoginError("Sign-in callback did not include an authorization code.");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const tokens = await exchangeAuthorizationCode({
|
|
126
|
+
discovery,
|
|
127
|
+
client,
|
|
128
|
+
redirectUri: callback.redirectUri,
|
|
129
|
+
code: returned.code,
|
|
130
|
+
verifier,
|
|
131
|
+
fetchImpl,
|
|
132
|
+
});
|
|
133
|
+
return tokens.access_token;
|
|
134
|
+
} finally {
|
|
135
|
+
await callback.close();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Trade an OAuth access token for an `mko_tkn_` key via POST <server>/api-keys.
|
|
141
|
+
*/
|
|
142
|
+
export async function createApiKey({ url, accessToken, keyName = defaultKeyName(), fetchImpl = globalThis.fetch }) {
|
|
143
|
+
const endpoint = apiKeysEndpoint(url);
|
|
144
|
+
const response = await timedFetch(fetchImpl, endpoint, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: {
|
|
147
|
+
Authorization: `Bearer ${accessToken}`,
|
|
148
|
+
"Content-Type": "application/json",
|
|
149
|
+
Accept: "application/json",
|
|
150
|
+
"User-Agent": USER_AGENT,
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify({ name: keyName }),
|
|
153
|
+
});
|
|
154
|
+
const body = await response.json().catch(() => null);
|
|
155
|
+
const console = consoleUrlFor(url);
|
|
156
|
+
|
|
157
|
+
if (response.status === 404 || response.status === 405) {
|
|
158
|
+
throw new BrowserLoginError(
|
|
159
|
+
"This Meko server does not support browser sign-in yet. " +
|
|
160
|
+
`Create an API key at ${console} > Settings > API Keys, then rerun with --api-key.`,
|
|
161
|
+
{ code: "unsupported", status: response.status },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (response.status === 429) {
|
|
165
|
+
throw new BrowserLoginError(
|
|
166
|
+
"You already have the maximum number of active API keys. " +
|
|
167
|
+
`Revoke one at ${console} > Settings > API Keys, then run the installer again.`,
|
|
168
|
+
{ code: "token_cap_reached", status: 429 },
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (response.status === 403 && body?.error?.code === "account_not_provisioned") {
|
|
172
|
+
// The browser signed in as a Cognito identity with no Meko account, e.g.
|
|
173
|
+
// Google in the browser but email and password in the console. The
|
|
174
|
+
// server refuses rather than minting a key for a new, empty account.
|
|
175
|
+
throw new BrowserLoginError(
|
|
176
|
+
`You signed in as a user that has no Meko account. Sign in at ${console} with the ` +
|
|
177
|
+
"method you normally use (for example Google, or email and password), then run the " +
|
|
178
|
+
"installer again and pick that same method. If the browser chose an account " +
|
|
179
|
+
`automatically, sign out at ${console} first. Or create a key at ${console} > ` +
|
|
180
|
+
"Settings > API Keys and rerun with --api-key.",
|
|
181
|
+
{ code: "account_not_provisioned", status: 403 },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (!response.ok || typeof body?.api_key !== "string" || !body.api_key.startsWith("mko_tkn_")) {
|
|
185
|
+
const detail = body?.error?.message ? `: ${body.error.message}` : "";
|
|
186
|
+
throw new BrowserLoginError(
|
|
187
|
+
`Could not create an API key (HTTP ${response.status})${detail}`,
|
|
188
|
+
{ code: body?.error?.code ?? "create_failed", status: response.status },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
apiKey: body.api_key,
|
|
193
|
+
id: body.id ?? null,
|
|
194
|
+
name: body.name ?? keyName,
|
|
195
|
+
expiresAt: body.expires_at ?? null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `https://mcp.mekodata.ai/mcp` → `https://mcp.mekodata.ai/api-keys`. */
|
|
200
|
+
export function apiKeysEndpoint(mcpUrl) {
|
|
201
|
+
const parsed = new URL(mcpUrl);
|
|
202
|
+
const base = parsed.pathname.replace(/\/+$/, "").replace(/\/mcp$/, "");
|
|
203
|
+
return `${parsed.origin}${base}/api-keys`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Console that issues keys for this MCP host. Dev keys only work on dev. */
|
|
207
|
+
export function consoleUrlFor(mcpUrl) {
|
|
208
|
+
try {
|
|
209
|
+
const host = new URL(mcpUrl).hostname;
|
|
210
|
+
if (host === "mekodev.com" || host.endsWith(".mekodev.com")) return "https://app.mekodev.com";
|
|
211
|
+
} catch {
|
|
212
|
+
// fall through
|
|
213
|
+
}
|
|
214
|
+
return MEKO_PRODUCTION_CONSOLE_URL;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function defaultKeyName({ hostname = os.hostname(), now = new Date() } = {}) {
|
|
218
|
+
const stamp = now.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
219
|
+
const host = String(hostname || "unknown-host").replace(/\.local$/, "");
|
|
220
|
+
const name = `Meko installer (${host}) ${stamp}`;
|
|
221
|
+
if (name.length <= MAX_KEY_NAME_LEN) return name;
|
|
222
|
+
const room = MAX_KEY_NAME_LEN - `Meko installer () ${stamp}`.length;
|
|
223
|
+
return `Meko installer (${host.slice(0, Math.max(room, 0))}) ${stamp}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// OAuth plumbing
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
export async function discoverOAuth(serverUrl, fetchImpl = globalThis.fetch) {
|
|
231
|
+
const challenge = await probeChallenge(serverUrl, fetchImpl);
|
|
232
|
+
const resourceMetadata = await firstJson(
|
|
233
|
+
protectedResourceUrls(serverUrl, challenge.resourceMetadata),
|
|
234
|
+
fetchImpl,
|
|
235
|
+
);
|
|
236
|
+
const authorizationServer = resourceMetadata?.authorization_servers?.[0] ?? new URL(serverUrl).origin;
|
|
237
|
+
const metadata = await firstJson(authorizationMetadataUrls(authorizationServer, serverUrl), fetchImpl);
|
|
238
|
+
if (!metadata?.authorization_endpoint || !metadata?.token_endpoint) {
|
|
239
|
+
throw new BrowserLoginError(
|
|
240
|
+
"This Meko endpoint does not advertise OAuth sign-in. Use --api-key instead.",
|
|
241
|
+
{ code: "unsupported" },
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
authorization_endpoint: metadata.authorization_endpoint,
|
|
246
|
+
token_endpoint: metadata.token_endpoint,
|
|
247
|
+
registration_endpoint: metadata.registration_endpoint ?? new URL("/register", authorizationServer).toString(),
|
|
248
|
+
scope: challenge.scope
|
|
249
|
+
?? resourceMetadata?.scopes_supported?.join(" ")
|
|
250
|
+
?? metadata.scopes_supported?.join(" ")
|
|
251
|
+
?? null,
|
|
252
|
+
resource: resourceMetadata?.resource ?? null,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function probeChallenge(serverUrl, fetchImpl) {
|
|
257
|
+
const response = await timedFetch(fetchImpl, serverUrl, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: {
|
|
260
|
+
"Content-Type": "application/json",
|
|
261
|
+
Accept: "application/json, text/event-stream",
|
|
262
|
+
"MCP-Protocol-Version": PROTOCOL_VERSION,
|
|
263
|
+
"User-Agent": USER_AGENT,
|
|
264
|
+
},
|
|
265
|
+
body: JSON.stringify({
|
|
266
|
+
jsonrpc: "2.0",
|
|
267
|
+
id: "oauth-discovery",
|
|
268
|
+
method: "initialize",
|
|
269
|
+
params: {
|
|
270
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
271
|
+
capabilities: {},
|
|
272
|
+
clientInfo: { name: "meko-installer", version: "1.0.0" },
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
const header = response.headers.get("www-authenticate") ?? "";
|
|
277
|
+
return {
|
|
278
|
+
resourceMetadata: challengeField(header, "resource_metadata"),
|
|
279
|
+
scope: challengeField(header, "scope"),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function registerClient(discovery, redirectUri, fetchImpl) {
|
|
284
|
+
const response = await timedFetch(fetchImpl, discovery.registration_endpoint, {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
|
|
287
|
+
body: JSON.stringify({
|
|
288
|
+
redirect_uris: [redirectUri],
|
|
289
|
+
token_endpoint_auth_method: "none",
|
|
290
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
291
|
+
response_types: ["code"],
|
|
292
|
+
client_name: "Meko installer",
|
|
293
|
+
software_id: "meko-installer",
|
|
294
|
+
software_version: "1.0.0",
|
|
295
|
+
...(discovery.scope ? { scope: discovery.scope } : {}),
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
const body = await response.json().catch(() => null);
|
|
299
|
+
if (!response.ok || !body?.client_id) {
|
|
300
|
+
throw new BrowserLoginError(
|
|
301
|
+
`OAuth client registration failed (HTTP ${response.status}). Use --api-key instead.`,
|
|
302
|
+
{ code: "registration_failed", status: response.status },
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return body;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function buildAuthorizationUrl({ discovery, client, redirectUri, state, challenge }) {
|
|
309
|
+
const url = new URL(discovery.authorization_endpoint);
|
|
310
|
+
url.search = new URLSearchParams({
|
|
311
|
+
response_type: "code",
|
|
312
|
+
client_id: client.client_id,
|
|
313
|
+
redirect_uri: redirectUri,
|
|
314
|
+
state,
|
|
315
|
+
code_challenge: challenge,
|
|
316
|
+
code_challenge_method: "S256",
|
|
317
|
+
...(discovery.scope ? { scope: discovery.scope } : {}),
|
|
318
|
+
...(discovery.resource ? { resource: discovery.resource } : {}),
|
|
319
|
+
}).toString();
|
|
320
|
+
return url.toString();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function exchangeAuthorizationCode({ discovery, client, redirectUri, code, verifier, fetchImpl }) {
|
|
324
|
+
const form = new URLSearchParams({
|
|
325
|
+
grant_type: "authorization_code",
|
|
326
|
+
code,
|
|
327
|
+
redirect_uri: redirectUri,
|
|
328
|
+
client_id: client.client_id,
|
|
329
|
+
code_verifier: verifier,
|
|
330
|
+
...(discovery.resource ? { resource: discovery.resource } : {}),
|
|
331
|
+
});
|
|
332
|
+
const headers = { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": USER_AGENT };
|
|
333
|
+
const method = client.token_endpoint_auth_method ?? "none";
|
|
334
|
+
if (method === "client_secret_basic") {
|
|
335
|
+
const id = encodeURIComponent(client.client_id);
|
|
336
|
+
const secret = encodeURIComponent(client.client_secret ?? "");
|
|
337
|
+
headers.Authorization = `Basic ${Buffer.from(`${id}:${secret}`).toString("base64")}`;
|
|
338
|
+
} else if (method === "client_secret_post" && client.client_secret) {
|
|
339
|
+
form.set("client_secret", client.client_secret);
|
|
340
|
+
}
|
|
341
|
+
const response = await timedFetch(fetchImpl, discovery.token_endpoint, { method: "POST", headers, body: form });
|
|
342
|
+
const body = await response.json().catch(() => null);
|
|
343
|
+
if (!response.ok || !body?.access_token) {
|
|
344
|
+
throw new BrowserLoginError(
|
|
345
|
+
`OAuth token exchange failed (HTTP ${response.status}). Run the installer again.`,
|
|
346
|
+
{ code: "token_exchange_failed", status: response.status },
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return body;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const CALLBACK_PAGE =
|
|
353
|
+
"<!doctype html><meta charset=utf-8><title>Meko sign-in</title>" +
|
|
354
|
+
"<body style=\"font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto\">" +
|
|
355
|
+
"<h1>Signed in to Meko</h1><p>You can close this window and return to the terminal.</p></body>";
|
|
356
|
+
|
|
357
|
+
async function createCallbackServer() {
|
|
358
|
+
let settle;
|
|
359
|
+
let fail;
|
|
360
|
+
const result = new Promise((resolve, reject) => {
|
|
361
|
+
settle = resolve;
|
|
362
|
+
fail = reject;
|
|
363
|
+
});
|
|
364
|
+
const server = http.createServer((req, res) => {
|
|
365
|
+
const requestUrl = new URL(req.url, "http://localhost");
|
|
366
|
+
if (requestUrl.pathname !== "/callback") {
|
|
367
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
368
|
+
res.end("Not found");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
372
|
+
res.end(CALLBACK_PAGE);
|
|
373
|
+
settle(Object.fromEntries(requestUrl.searchParams));
|
|
374
|
+
});
|
|
375
|
+
await new Promise((resolve, reject) => {
|
|
376
|
+
server.once("error", reject);
|
|
377
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
378
|
+
});
|
|
379
|
+
server.on("error", (err) => fail(err));
|
|
380
|
+
const { port } = server.address();
|
|
381
|
+
return {
|
|
382
|
+
redirectUri: `http://localhost:${port}/callback`,
|
|
383
|
+
wait: (timeoutMs) => {
|
|
384
|
+
const timer = setTimeout(
|
|
385
|
+
() =>
|
|
386
|
+
fail(
|
|
387
|
+
new BrowserLoginError(
|
|
388
|
+
`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for browser sign-in. ` +
|
|
389
|
+
"Run the installer again, or use --api-key.",
|
|
390
|
+
{ code: "timeout" },
|
|
391
|
+
),
|
|
392
|
+
),
|
|
393
|
+
timeoutMs,
|
|
394
|
+
);
|
|
395
|
+
timer.unref?.();
|
|
396
|
+
return result.finally(() => clearTimeout(timer));
|
|
397
|
+
},
|
|
398
|
+
close: () =>
|
|
399
|
+
new Promise((resolve) => {
|
|
400
|
+
server.closeAllConnections?.();
|
|
401
|
+
server.close(() => resolve());
|
|
402
|
+
}),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function openDefaultBrowser(url, { platform = process.platform, spawnImpl = spawn } = {}) {
|
|
407
|
+
const command = platform === "darwin" ? "open" : platform === "win32" ? "rundll32" : "xdg-open";
|
|
408
|
+
const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
409
|
+
try {
|
|
410
|
+
const child = spawnImpl(command, args, { detached: true, stdio: "ignore" });
|
|
411
|
+
return await new Promise((resolve) => {
|
|
412
|
+
child.once("error", () => resolve(false));
|
|
413
|
+
child.once("spawn", () => {
|
|
414
|
+
child.unref();
|
|
415
|
+
resolve(true);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
} catch {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function protectedResourceUrls(serverUrl, advertised) {
|
|
424
|
+
const parsed = new URL(serverUrl);
|
|
425
|
+
const urls = [];
|
|
426
|
+
if (advertised) urls.push(advertised);
|
|
427
|
+
if (parsed.pathname && parsed.pathname !== "/") {
|
|
428
|
+
urls.push(`${parsed.origin}/.well-known/oauth-protected-resource${parsed.pathname}`);
|
|
429
|
+
}
|
|
430
|
+
urls.push(`${parsed.origin}/.well-known/oauth-protected-resource`);
|
|
431
|
+
return [...new Set(urls)];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function authorizationMetadataUrls(authServer, serverUrl) {
|
|
435
|
+
const parsed = new URL(authServer || serverUrl);
|
|
436
|
+
if (parsed.pathname && parsed.pathname !== "/") {
|
|
437
|
+
const path = parsed.pathname.replace(/\/$/, "");
|
|
438
|
+
return [
|
|
439
|
+
`${parsed.origin}/.well-known/oauth-authorization-server${path}`,
|
|
440
|
+
`${parsed.origin}/.well-known/openid-configuration${path}`,
|
|
441
|
+
`${parsed.origin}${path}/.well-known/openid-configuration`,
|
|
442
|
+
];
|
|
443
|
+
}
|
|
444
|
+
return [
|
|
445
|
+
`${parsed.origin}/.well-known/oauth-authorization-server`,
|
|
446
|
+
`${parsed.origin}/.well-known/openid-configuration`,
|
|
447
|
+
];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function firstJson(urls, fetchImpl) {
|
|
451
|
+
for (const url of urls) {
|
|
452
|
+
const response = await timedFetch(fetchImpl, url, {
|
|
453
|
+
headers: { "MCP-Protocol-Version": PROTOCOL_VERSION, "User-Agent": USER_AGENT },
|
|
454
|
+
});
|
|
455
|
+
if (!response.ok) continue;
|
|
456
|
+
try {
|
|
457
|
+
return await response.json();
|
|
458
|
+
} catch {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function timedFetch(fetchImpl, url, options) {
|
|
466
|
+
const controller = new AbortController();
|
|
467
|
+
const timer = setTimeout(() => controller.abort(), DEFAULT_REQUEST_TIMEOUT_MS);
|
|
468
|
+
timer.unref?.();
|
|
469
|
+
try {
|
|
470
|
+
return await fetchImpl(url, { ...options, signal: controller.signal });
|
|
471
|
+
} catch (err) {
|
|
472
|
+
if (err?.name === "AbortError") {
|
|
473
|
+
throw new BrowserLoginError(`Request timed out: ${url}`, { code: "network" });
|
|
474
|
+
}
|
|
475
|
+
throw new BrowserLoginError(`Request failed: ${err?.message ?? String(err)}`, { code: "network" });
|
|
476
|
+
} finally {
|
|
477
|
+
clearTimeout(timer);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function challengeField(header, field) {
|
|
482
|
+
const match = new RegExp(`${field}=(?:"([^"]+)"|([^\\s,]+))`, "i").exec(header);
|
|
483
|
+
return match?.[1] ?? match?.[2] ?? null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function safeEqual(left, right) {
|
|
487
|
+
if (typeof left !== "string" || typeof right !== "string") return false;
|
|
488
|
+
const a = Buffer.from(left);
|
|
489
|
+
const b = Buffer.from(right);
|
|
490
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function base64url(value) {
|
|
494
|
+
return Buffer.from(value).toString("base64url");
|
|
495
|
+
}
|
package/lib/clients/opencode.mjs
CHANGED
|
@@ -337,6 +337,7 @@ export function createOpenCodeAdapter(customPaths = {}) {
|
|
|
337
337
|
};
|
|
338
338
|
}
|
|
339
339
|
let resolved;
|
|
340
|
+
log?.dim?.(" Reading OpenCode's resolved configuration. The first run after install can take a minute while OpenCode installs plugin dependencies.");
|
|
340
341
|
try {
|
|
341
342
|
resolved = await Promise.resolve(readResolvedConfig({
|
|
342
343
|
bin: found.path || "opencode",
|
|
@@ -378,12 +379,16 @@ export function createOpenCodeAdapter(customPaths = {}) {
|
|
|
378
379
|
},
|
|
379
380
|
};
|
|
380
381
|
}
|
|
382
|
+
// The first OpenCode run after install writes package.json with
|
|
383
|
+
// @opencode-ai/plugin and installs it before resolving config. That took 80 s
|
|
384
|
+
// in one measured run; warm runs take under a second.
|
|
385
|
+
const RESOLVED_CONFIG_TIMEOUT_MS = 120_000;
|
|
381
386
|
function defaultReadResolvedConfig({ bin, cwd }) {
|
|
382
387
|
const result = spawnSync(bin, ["debug", "config"], {
|
|
383
388
|
cwd,
|
|
384
389
|
encoding: "utf8",
|
|
385
390
|
maxBuffer: 16 * 1024 * 1024,
|
|
386
|
-
timeout:
|
|
391
|
+
timeout: RESOLVED_CONFIG_TIMEOUT_MS,
|
|
387
392
|
});
|
|
388
393
|
if (result.error) throw result.error;
|
|
389
394
|
if (result.status !== 0) {
|