@yugabytedb/meko-mcp 2.3.0 → 2.5.1

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 (42) hide show
  1. package/README.md +88 -35
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +735 -74
  3. package/assets/hooks-handlers/lib/capture-notices.js +94 -1
  4. package/assets/hooks-handlers/lib/capture-state.js +111 -0
  5. package/assets/hooks-handlers/lib/capture.js +130 -4
  6. package/assets/hooks-handlers/lib/checkpoint-timer.js +394 -8
  7. package/assets/hooks-handlers/lib/opencode-handler.js +29 -6
  8. package/assets/scripts/meko-statusline.sh +17 -1
  9. package/assets/skills/meko-mcp-tools/SKILL.md +6 -5
  10. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  11. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +194 -10
  12. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -3
  13. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +11 -6
  14. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +4 -4
  15. package/assets/skills/meko-mcp-tools/references/tools-overview.md +19 -14
  16. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +69 -8
  17. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +3 -1
  18. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +8 -7
  19. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +4 -4
  20. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  21. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +194 -10
  22. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +10 -5
  23. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +4 -4
  24. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +19 -14
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +68 -8
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -1
  27. package/assets/skills/meko-select-datapack-desktop/SKILL.md +91 -12
  28. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +39 -7
  29. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +8 -6
  30. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +13 -1
  31. package/bin/create-meko-setup.mjs +152 -35
  32. package/lib/autofix.mjs +100 -0
  33. package/lib/browser-login.mjs +495 -0
  34. package/lib/clients/codex.mjs +1 -24
  35. package/lib/clients/common.mjs +25 -0
  36. package/lib/clients/opencode.mjs +6 -1
  37. package/lib/migrate/datapack.mjs +254 -16
  38. package/lib/migrate/mcp-client.mjs +34 -0
  39. package/lib/migrate/orchestrator.mjs +1 -1
  40. package/lib/migrate.mjs +2 -6
  41. package/lib/validate.mjs +111 -22
  42. 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
+ }
@@ -38,6 +38,7 @@ import {
38
38
  import {
39
39
  getInstallerVersion,
40
40
  installStableTree,
41
+ isLocalUrl,
41
42
  isPackagedInstall,
42
43
  isDanglingSymlink,
43
44
  markerIsStale,
@@ -909,30 +910,6 @@ function buildCliRegisterCommand(name, url, bearerEnv) {
909
910
  return cmd;
910
911
  }
911
912
 
912
- /**
913
- * True for localhost / loopback MCP URLs (no cloud WAF in front). Parses the
914
- * URL and compares the HOSTNAME exactly — a substring test would wrongly
915
- * classify `https://localhost.example.com` (a real remote host) as local and
916
- * skip the WAF User-Agent header. Unparseable input is treated as non-local
917
- * (cloud), i.e. we err toward setting the header.
918
- */
919
- function isLocalUrl(url) {
920
- let host;
921
- try {
922
- host = new URL(String(url ?? "")).hostname;
923
- } catch {
924
- return false;
925
- }
926
- // `new URL` strips the brackets from IPv6 literals, so `[::1]` → `::1`.
927
- host = host.replace(/^\[|\]$/g, "").toLowerCase();
928
- return (
929
- host === "localhost" ||
930
- host === "::1" ||
931
- // Entire 127.0.0.0/8 block is loopback, not just 127.0.0.1.
932
- /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)
933
- );
934
- }
935
-
936
913
  const HTTP_HEADER_USER_AGENT_KEY = "User-Agent";
937
914
 
938
915
  // Matches one `KEY = "VALUE"` (or single-quoted) pair. KEY may be
@@ -114,6 +114,31 @@ export function run(cmd) {
114
114
  return execSync(cmd, { encoding: "utf8", stdio: "pipe" }).trim();
115
115
  }
116
116
 
117
+ /**
118
+ * True for localhost / loopback MCP URLs (no cloud WAF in front, no browser
119
+ * sign-in). Parses the URL and compares the HOSTNAME exactly — a substring
120
+ * test would wrongly classify `https://localhost.example.com` (a real remote
121
+ * host) as local and `http://[::1]/mcp` as cloud. Unparseable input is
122
+ * treated as non-local (cloud): Codex errs toward setting the WAF User-Agent
123
+ * header, and the installer toward requiring an API key.
124
+ */
125
+ export function isLocalUrl(url) {
126
+ let host;
127
+ try {
128
+ host = new URL(String(url ?? "")).hostname;
129
+ } catch {
130
+ return false;
131
+ }
132
+ // `new URL` strips the brackets from IPv6 literals, so `[::1]` → `::1`.
133
+ host = host.replace(/^\[|\]$/g, "").toLowerCase();
134
+ return (
135
+ host === "localhost" ||
136
+ host === "::1" ||
137
+ // Entire 127.0.0.0/8 block is loopback, not just 127.0.0.1.
138
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)
139
+ );
140
+ }
141
+
117
142
  /**
118
143
  * @param {string|null|undefined} apiKey
119
144
  * @returns {string}
@@ -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: 10_000,
391
+ timeout: RESOLVED_CONFIG_TIMEOUT_MS,
387
392
  });
388
393
  if (result.error) throw result.error;
389
394
  if (result.status !== 0) {