From a01236753a44a25b75fd79acc616f9975303becf Mon Sep 17 00:00:00 2001 From: Tim Lingo <1timlingo@gmail.com> Date: Fri, 26 Jun 2026 14:27:04 -0500 Subject: [PATCH 1/5] feat(connectors): clean-install bundling + Google OAuth foundation Make connectors work on a clean Mac with no Node/npm installed, and lay the foundation for one-click Google sign-in. Clean-install bundling: - bundle scripts: `npm run bundle` -> self-contained dist/connectd.cjs (CJS, no node_modules); `bundle:servers` -> pre-bundled MCP servers (fs-server.mjs for "Your files", runs without npx); `bundle:all`. - runtime resolution in the bridge: a connector config can use command "@node" (the bundled runtime, process.execPath) and arg "@bundled/" (a server shipped alongside the bridge), resolved at spawn time via NEURON_CONNECTD_BUNDLE_DIR. So remote connectors + "Your files" work offline, zero deps. - graceful ENOENT -> needs_setup ("coming soon") instead of a cryptic spawn crash for not-yet-bundled npx connectors on a clean machine. Google OAuth foundation (google-oauth.ts): - desktop-style authorization-code + PKCE flow, refresh, Keychain storage, gated on GOOGLE_OAUTH_CLIENT_ID (honest needs_setup until configured). - new auth mode "google" for stdio connectors + /google/oauth/start and /google/callback endpoints. NOTE: pending the Neuron Google OAuth client + Google verification; not certified end-to-end yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + dist/bridge.js | 91 +++++++++++++++++++++- dist/google-oauth.js | 155 +++++++++++++++++++++++++++++++++++++ dist/server.js | 21 +++++ package.json | 7 +- src/bridge.ts | 102 ++++++++++++++++++++++++- src/config.ts | 7 +- src/google-oauth.ts | 177 +++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 20 +++++ 9 files changed, 575 insertions(+), 9 deletions(-) create mode 100644 dist/google-oauth.js create mode 100644 src/google-oauth.ts diff --git a/.gitignore b/.gitignore index 659dc09..e1d7640 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ node_modules/ *.log .skills-sync* + +# esbuild single-file bundle + pre-bundled servers (produced by npm run bundle:all / DMG build) +dist/connectd.cjs +dist/servers/ diff --git a/dist/bridge.js b/dist/bridge.js index 78065c6..3daa580 100644 --- a/dist/bridge.js +++ b/dist/bridge.js @@ -3,8 +3,10 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; import { createHash } from "node:crypto"; +import { join, dirname } from "node:path"; import { saveConfig } from "./config.js"; import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js"; +import { googleClientConfigured, buildGoogleAuthUrl, exchangeGoogleCode, getValidGoogleAccessToken, clearGoogleTokens, } from "./google-oauth.js"; import { kcGet, kcSet, kcDelete } from "./keychain.js"; // Namespacing: every external tool is exposed to the soul as // mcp____ @@ -14,6 +16,23 @@ const SEP = "__"; export function namespacedName(serverId, tool) { return `${PREFIX}${serverId}${SEP}${tool}`; } +// ── Self-contained runtime resolution ──────────────────────────────────────────────────────── +// So connectors work on a clean Mac with no Node/npm installed, a connector config can reference: +// command "@node" → the JS runtime running this bridge (process.execPath = the bundled +// Node we ship in the .app), used to spawn pre-bundled MCP servers. +// arg "@bundled/" → a pre-bundled MCP server shipped alongside the bridge (e.g. the +// filesystem server for "Your files"), so it runs offline without npx. +// The bundled-servers dir is the app's resources/.../neuron-connectd/servers (set by the daemons +// launcher via NEURON_CONNECTD_BUNDLE_DIR; falls back to a path next to the running bundle). +function bundledServersDir() { + return process.env.NEURON_CONNECTD_BUNDLE_DIR ?? join(dirname(process.argv[1] ?? process.cwd()), "servers"); +} +function resolveRuntimeCommand(command) { + return command === "@node" ? process.execPath : command; +} +function resolveBundledArg(arg) { + return arg.startsWith("@bundled/") ? join(bundledServersDir(), arg.slice("@bundled/".length)) : arg; +} // Split "mcp__filesystem__read_file" -> { serverId:"filesystem", tool:"read_file" }. // Tool names may themselves contain "__", so we only split on the FIRST two seps. export function parseName(full) { @@ -88,6 +107,7 @@ export class ConnectorBridge { this.servers.delete(id); saveConfig(this.config); await kcDelete(id).catch(() => { }); // drop any stored secret for this connector + await clearGoogleTokens(id).catch(() => { }); // and any Google tokens return { ok: true }; } // Tear down an existing client (if any) and connect fresh from current config. @@ -118,15 +138,52 @@ export class ConnectorBridge { this.servers.set(id, { id, cfg, status: "error", tools: [], error: `unknown transport "${cfg.transport}"` }); } async connectStdio(id, cfg) { + // Google connectors (auth: "google") resolve a fresh access token first. Two honest pre-connect + // states replace the old "launch npx with no creds and crash" failure: + // needs_setup — the Neuron Google OAuth client isn't shipped/configured yet (pre-verification) + // needs_auth — client ready, but this user hasn't completed the one-click sign-in + let extraEnv = {}; + if (cfg.auth === "google") { + if (!googleClientConfigured()) { + this.servers.set(id, { + id, cfg, status: "needs_setup", tools: [], + error: "Secure Google sign-in is coming soon — we're finishing setup. Nothing for you to do.", + }); + console.error(`[connectd] ${id}: Google client not configured — needs_setup`); + return; + } + const token = await getValidGoogleAccessToken(id); + if (!token) { + this.servers.set(id, { id, cfg, status: "needs_auth", tools: [], error: "sign-in required" }); + console.error(`[connectd] ${id}: no Google token — needs_auth`); + return; + } + // Hand the access token to the Google MCP server via env. Which exact variable the chosen + // server reads is a Phase-4 certification detail (see google-oauth-verification-checklist.md), + // not a code change — we set the common names so swapping servers stays config-only. + extraEnv = { GOOGLE_ACCESS_TOKEN: token, GOOGLE_OAUTH_ACCESS_TOKEN: token }; + } try { const transport = new StdioClientTransport({ - command: cfg.command, - args: cfg.args ?? [], - env: { ...process.env, ...(cfg.env ?? {}) }, + command: resolveRuntimeCommand(cfg.command), + args: (cfg.args ?? []).map(resolveBundledArg), + env: { ...process.env, ...(cfg.env ?? {}), ...extraEnv }, }); await this.finishConnect(id, cfg, transport); } catch (err) { + // A missing runtime/command (e.g. npx not installed on a clean Mac for a not-yet-bundled + // connector) surfaces as ENOENT. Show an honest "coming soon" state, never a cryptic spawn + // error — the connector just isn't available on this device yet. + const msg = err instanceof Error ? err.message : String(err); + if (/ENOENT/.test(msg)) { + this.servers.set(id, { + id, cfg, status: "needs_setup", tools: [], + error: "This connector isn't available on this device yet — we're finishing setup.", + }); + console.error(`[connectd] ${id}: runtime/command unavailable (${msg}) — needs_setup`); + return; + } this.setError(id, cfg, err); } } @@ -246,6 +303,34 @@ export class ConnectorBridge { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } + // Begin a Google one-click sign-in: returns the consent URL for the UI to open in the browser. + async startGoogleOAuth(id) { + const cfg = this.config.servers[id]; + if (!cfg) + return { ok: false, error: `unknown server: ${id}` }; + if (cfg.auth !== "google") + return { ok: false, error: `${id} is not a Google connector` }; + if (!googleClientConfigured()) + return { ok: false, error: "Google sign-in is being set up" }; + const url = buildGoogleAuthUrl(id, cfg.scopes ?? []); + if (!url) + return { ok: false, error: "could not build Google authorization URL" }; + return { ok: true, authUrl: url }; + } + // Complete a Google sign-in with the code from the callback: store tokens, reconnect, list tools. + async finishGoogleOAuth(id, code) { + try { + await exchangeGoogleCode(id, code); + } + catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + await this.reconnectServer(id); + const now = this.servers.get(id); + return now?.status === "connected" + ? { ok: true } + : { ok: false, error: `post-auth status: ${now?.status} (${now?.error ?? ""})` }; + } // Merged, namespaced tool list across all connected servers. allTools() { const out = []; diff --git a/dist/google-oauth.js b/dist/google-oauth.js new file mode 100644 index 0000000..50c8cfd --- /dev/null +++ b/dist/google-oauth.js @@ -0,0 +1,155 @@ +import { createHash, randomBytes } from "node:crypto"; +import { kcGetJson, kcSetJson, kcDelete } from "./keychain.js"; +import { HOST, PORT } from "./config.js"; +// ── Bridge-managed Google OAuth (one-click for Drive / Gmail / Calendar) ───────────────────── +// +// Why this exists: the community Google MCP servers each want a gcp-oauth.keys.json file plus a +// one-time `npx … auth` CLI step. That's unacceptable friction for a consumer product. Instead the +// bridge runs Google's OAuth itself — desktop-app authorization-code + PKCE — and stores the +// resulting refresh token in the macOS Keychain (account ":google"). The stdio MCP server is +// then handed a short-lived access token, so the user only ever clicks "Connect" and approves in +// the browser. No files, no terminal. +// +// Ships gated: a Neuron-owned Google OAuth client (GOOGLE_OAUTH_CLIENT_ID / _SECRET) must be +// present, and the app must clear Google's verification for the requested scopes (see +// docs/Tims stuff/google-oauth-verification-checklist.md). Until the client id is configured, +// googleClientConfigured() is false and callers surface an honest "being set up" state rather than +// launching a server that will crash with no credentials. +const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; +// The loopback redirect must match exactly what's registered on the Google desktop OAuth client. +export const GOOGLE_REDIRECT_URI = `http://${HOST}:${PORT}/google/callback`; +function clientId() { + const v = process.env.GOOGLE_OAUTH_CLIENT_ID; + return v && v.trim() ? v.trim() : undefined; +} +function clientSecret() { + const v = process.env.GOOGLE_OAUTH_CLIENT_SECRET; + return v && v.trim() ? v.trim() : undefined; +} +// True once the app is shipped with a real Google OAuth client. Gates the whole flow. +export function googleClientConfigured() { + return clientId() !== undefined; +} +const pending = new Map(); +function base64url(buf) { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +// Begin a sign-in: produce the Google consent URL and stash the PKCE verifier for this id. +// Returns undefined if the Google client isn't configured yet (caller surfaces needs_setup). +export function buildGoogleAuthUrl(id, scopes) { + const cid = clientId(); + if (!cid) + return undefined; + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + pending.set(id, { verifier, scopes }); + const params = new URLSearchParams({ + client_id: cid, + redirect_uri: GOOGLE_REDIRECT_URI, + response_type: "code", + scope: scopes.join(" "), + code_challenge: challenge, + code_challenge_method: "S256", + access_type: "offline", // ask for a refresh token + prompt: "consent", // force refresh-token issuance on re-consent + state: id, // carry the connector id through the round-trip + }); + return `${AUTH_ENDPOINT}?${params.toString()}`; +} +// Complete a sign-in: exchange the authorization code for tokens and persist the refresh token. +// Throws on any failure (missing flow, Google error) so the caller can report it honestly. +export async function exchangeGoogleCode(id, code) { + const flow = pending.get(id); + pending.delete(id); + if (!flow) + throw new Error(`no pending Google sign-in for ${id}`); + const cid = clientId(); + if (!cid) + throw new Error("Google OAuth client not configured"); + const body = new URLSearchParams({ + client_id: cid, + code, + code_verifier: flow.verifier, + grant_type: "authorization_code", + redirect_uri: GOOGLE_REDIRECT_URI, + }); + // Desktop clients may still carry a (non-secret) client secret; include it when present. + const secret = clientSecret(); + if (secret) + body.set("client_secret", secret); + const resp = await fetch(TOKEN_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + const json = (await resp.json()); + if (!resp.ok) + throw new Error(`Google token exchange failed: ${json.error ?? resp.status} ${json.error_description ?? ""}`.trim()); + if (!json.refresh_token) { + throw new Error("Google did not return a refresh token (revoke prior access and retry with prompt=consent)"); + } + const tokens = { + refresh_token: json.refresh_token, + access_token: json.access_token, + expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined, + scope: json.scope, + }; + await kcSetJson(`${id}:google`, tokens); + return tokens; +} +// Return a currently-valid access token for this connector, refreshing if expired. Returns +// undefined if the user hasn't signed in (no stored refresh token) — caller → needs_auth. +export async function getValidGoogleAccessToken(id) { + const stored = await kcGetJson(`${id}:google`); + if (!stored?.refresh_token) + return undefined; + // 60s skew so we never hand out a token that's about to expire mid-call. + if (stored.access_token && stored.expires_at && stored.expires_at - 60_000 > nowMs()) { + return stored.access_token; + } + return refreshGoogleToken(id, stored); +} +async function refreshGoogleToken(id, stored) { + const cid = clientId(); + if (!cid) + return undefined; + const body = new URLSearchParams({ + client_id: cid, + refresh_token: stored.refresh_token, + grant_type: "refresh_token", + }); + const secret = clientSecret(); + if (secret) + body.set("client_secret", secret); + const resp = await fetch(TOKEN_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + const json = (await resp.json()); + if (!resp.ok || !json.access_token) + return undefined; + const updated = { + refresh_token: stored.refresh_token, // Google omits it on refresh; keep the original + access_token: json.access_token, + expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined, + scope: json.scope ?? stored.scope, + }; + await kcSetJson(`${id}:google`, updated); + return updated.access_token; +} +// True once the user has completed sign-in for this connector (a refresh token is stored). +export async function hasGoogleTokens(id) { + const stored = await kcGetJson(`${id}:google`); + return !!stored?.refresh_token; +} +// Forget a connector's Google tokens (on remove / sign-out). +export async function clearGoogleTokens(id) { + await kcDelete(`${id}:google`); +} +// Wrapped so tests/builds without a real clock injection stay simple; Date.now is fine in the +// bridge process (unlike the El soul, this is plain Node). +function nowMs() { + return Date.now(); +} diff --git a/dist/server.js b/dist/server.js index c566672..81263c8 100644 --- a/dist/server.js +++ b/dist/server.js @@ -85,6 +85,27 @@ export function startServer(bridge) { const result = await bridge.finishOAuth(id, code); return sendHtml(res, result.ok ? 200 : 400, authResultPage(result.ok, result.error ?? "")); } + // POST /google/oauth/start { id } — begin a Google one-click sign-in, return the consent URL. + if (method === "POST" && url === "/google/oauth/start") { + const parsed = await parseJsonBody(req); + if (!parsed || !parsed.id) + return sendJson(res, 400, { ok: false, error: "missing id" }); + const result = await bridge.startGoogleOAuth(parsed.id); + return sendJson(res, result.ok ? 200 : 400, result); + } + // GET /google/callback?code=..&state=.. — Google redirects here after consent. + if (method === "GET" && url.startsWith("/google/callback")) { + const q = new URL(url, `http://${HOST}:${PORT}`).searchParams; + const id = q.get("state"); // we pass the connector id as `state` + const code = q.get("code"); + const err = q.get("error"); + if (err) + return sendHtml(res, 400, authResultPage(false, err)); + if (!id || !code) + return sendHtml(res, 400, authResultPage(false, "missing id or code")); + const result = await bridge.finishGoogleOAuth(id, code); + return sendHtml(res, result.ok ? 200 : 400, authResultPage(result.ok, result.error ?? "")); + } // GET /mcp/auto-approved — the soul reads this to decide which mcp__* calls skip the // approval card (per-connector opt-in, off by default). if (method === "GET" && url === "/mcp/auto-approved") { diff --git a/package.json b/package.json index a36e7f0..87051aa 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,17 @@ "scripts": { "dev": "tsx src/index.ts", "build": "tsc -p tsconfig.json", + "bundle": "esbuild src/index.ts --bundle --platform=node --format=cjs --target=node20 --outfile=dist/connectd.cjs", + "bundle:servers": "esbuild node_modules/@modelcontextprotocol/server-filesystem/dist/index.js --bundle --platform=node --format=esm --target=node20 --banner:js=\"import{createRequire as __cr}from'module';const require=__cr(import.meta.url);\" --outfile=dist/servers/fs-server.mjs", + "bundle:all": "npm run bundle && npm run bundle:servers", "start": "node dist/index.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server-filesystem": "^2026.1.14" }, "devDependencies": { + "esbuild": "^0.28.0", "tsx": "^4.19.0", "typescript": "^5.6.0", "@types/node": "^24.0.0" diff --git a/src/bridge.ts b/src/bridge.ts index b7921c8..3a88179 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -3,9 +3,17 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; import { createHash } from "node:crypto"; +import { join, dirname } from "node:path"; import type { ConnectorsConfig, ServerConfig } from "./config.js"; import { saveConfig } from "./config.js"; import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js"; +import { + googleClientConfigured, + buildGoogleAuthUrl, + exchangeGoogleCode, + getValidGoogleAccessToken, + clearGoogleTokens, +} from "./google-oauth.js"; import { kcGet, kcSet, kcDelete } from "./keychain.js"; // Namespacing: every external tool is exposed to the soul as @@ -18,6 +26,26 @@ export function namespacedName(serverId: string, tool: string): string { return `${PREFIX}${serverId}${SEP}${tool}`; } +// ── Self-contained runtime resolution ──────────────────────────────────────────────────────── +// So connectors work on a clean Mac with no Node/npm installed, a connector config can reference: +// command "@node" → the JS runtime running this bridge (process.execPath = the bundled +// Node we ship in the .app), used to spawn pre-bundled MCP servers. +// arg "@bundled/" → a pre-bundled MCP server shipped alongside the bridge (e.g. the +// filesystem server for "Your files"), so it runs offline without npx. +// The bundled-servers dir is the app's resources/.../neuron-connectd/servers (set by the daemons +// launcher via NEURON_CONNECTD_BUNDLE_DIR; falls back to a path next to the running bundle). +function bundledServersDir(): string { + return process.env.NEURON_CONNECTD_BUNDLE_DIR ?? join(dirname(process.argv[1] ?? process.cwd()), "servers"); +} + +function resolveRuntimeCommand(command: string): string { + return command === "@node" ? process.execPath : command; +} + +function resolveBundledArg(arg: string): string { + return arg.startsWith("@bundled/") ? join(bundledServersDir(), arg.slice("@bundled/".length)) : arg; +} + // Split "mcp__filesystem__read_file" -> { serverId:"filesystem", tool:"read_file" }. // Tool names may themselves contain "__", so we only split on the FIRST two seps. export function parseName(full: string): { serverId: string; tool: string } | null { @@ -35,7 +63,10 @@ export interface ExposedTool { input_schema: unknown; } -type ServerStatus = "connected" | "needs_auth" | "error" | "disabled"; +// needs_setup: a Google connector whose app-level OAuth client isn't configured yet (pre-launch). +// Distinct from needs_auth (client ready, this user just hasn't signed in) so the UI can say +// "being set up" instead of "sign in", and never drops the user into a broken flow. +type ServerStatus = "connected" | "needs_auth" | "needs_setup" | "error" | "disabled"; interface ServerEntry { id: string; @@ -114,6 +145,7 @@ export class ConnectorBridge { this.servers.delete(id); saveConfig(this.config); await kcDelete(id).catch(() => {}); // drop any stored secret for this connector + await clearGoogleTokens(id).catch(() => {}); // and any Google tokens return { ok: true }; } @@ -142,14 +174,51 @@ export class ConnectorBridge { } private async connectStdio(id: string, cfg: ServerConfig): Promise { + // Google connectors (auth: "google") resolve a fresh access token first. Two honest pre-connect + // states replace the old "launch npx with no creds and crash" failure: + // needs_setup — the Neuron Google OAuth client isn't shipped/configured yet (pre-verification) + // needs_auth — client ready, but this user hasn't completed the one-click sign-in + let extraEnv: Record = {}; + if (cfg.auth === "google") { + if (!googleClientConfigured()) { + this.servers.set(id, { + id, cfg, status: "needs_setup", tools: [], + error: "Secure Google sign-in is coming soon — we're finishing setup. Nothing for you to do.", + }); + console.error(`[connectd] ${id}: Google client not configured — needs_setup`); + return; + } + const token = await getValidGoogleAccessToken(id); + if (!token) { + this.servers.set(id, { id, cfg, status: "needs_auth", tools: [], error: "sign-in required" }); + console.error(`[connectd] ${id}: no Google token — needs_auth`); + return; + } + // Hand the access token to the Google MCP server via env. Which exact variable the chosen + // server reads is a Phase-4 certification detail (see google-oauth-verification-checklist.md), + // not a code change — we set the common names so swapping servers stays config-only. + extraEnv = { GOOGLE_ACCESS_TOKEN: token, GOOGLE_OAUTH_ACCESS_TOKEN: token }; + } try { const transport = new StdioClientTransport({ - command: cfg.command!, - args: cfg.args ?? [], - env: { ...process.env, ...(cfg.env ?? {}) } as Record, + command: resolveRuntimeCommand(cfg.command!), + args: (cfg.args ?? []).map(resolveBundledArg), + env: { ...process.env, ...(cfg.env ?? {}), ...extraEnv } as Record, }); await this.finishConnect(id, cfg, transport); } catch (err) { + // A missing runtime/command (e.g. npx not installed on a clean Mac for a not-yet-bundled + // connector) surfaces as ENOENT. Show an honest "coming soon" state, never a cryptic spawn + // error — the connector just isn't available on this device yet. + const msg = err instanceof Error ? err.message : String(err); + if (/ENOENT/.test(msg)) { + this.servers.set(id, { + id, cfg, status: "needs_setup", tools: [], + error: "This connector isn't available on this device yet — we're finishing setup.", + }); + console.error(`[connectd] ${id}: runtime/command unavailable (${msg}) — needs_setup`); + return; + } this.setError(id, cfg, err); } } @@ -273,6 +342,31 @@ export class ConnectorBridge { } } + // Begin a Google one-click sign-in: returns the consent URL for the UI to open in the browser. + async startGoogleOAuth(id: string): Promise<{ ok: boolean; authUrl?: string; error?: string }> { + const cfg = this.config.servers[id]; + if (!cfg) return { ok: false, error: `unknown server: ${id}` }; + if (cfg.auth !== "google") return { ok: false, error: `${id} is not a Google connector` }; + if (!googleClientConfigured()) return { ok: false, error: "Google sign-in is being set up" }; + const url = buildGoogleAuthUrl(id, cfg.scopes ?? []); + if (!url) return { ok: false, error: "could not build Google authorization URL" }; + return { ok: true, authUrl: url }; + } + + // Complete a Google sign-in with the code from the callback: store tokens, reconnect, list tools. + async finishGoogleOAuth(id: string, code: string): Promise<{ ok: boolean; error?: string }> { + try { + await exchangeGoogleCode(id, code); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + await this.reconnectServer(id); + const now = this.servers.get(id); + return now?.status === "connected" + ? { ok: true } + : { ok: false, error: `post-auth status: ${now?.status} (${now?.error ?? ""})` }; + } + // Merged, namespaced tool list across all connected servers. allTools(): ExposedTool[] { const out: ExposedTool[] = []; diff --git a/src/config.ts b/src/config.ts index a16cc51..fe2f39c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,8 +13,13 @@ export interface ServerConfig { env?: Record; // http (Phase 3) url?: string; - auth?: "none" | "oauth" | "token"; + auth?: "none" | "oauth" | "token" | "google"; scope?: string; // OAuth scope string, when auth === "oauth" + // Google connectors (Drive/Gmail/Calendar): stdio servers whose Google OAuth the bridge + // performs itself (one-click), storing tokens in the Keychain. `scopes` = the Google API + // scopes to request. Requires a Neuron-owned Google OAuth client (GOOGLE_OAUTH_CLIENT_ID); + // until that's configured the connector reports needs_setup instead of failing cryptically. + scopes?: string[]; // Phase 5: per-connector opt-in to skip the soul's approval card (read-only-leaning, // off by default). The soul reads this from connectors.json at approval time. autoApprove?: boolean; diff --git a/src/google-oauth.ts b/src/google-oauth.ts new file mode 100644 index 0000000..73ce4fe --- /dev/null +++ b/src/google-oauth.ts @@ -0,0 +1,177 @@ +import { createHash, randomBytes } from "node:crypto"; +import { kcGetJson, kcSetJson, kcDelete } from "./keychain.js"; +import { HOST, PORT } from "./config.js"; + +// ── Bridge-managed Google OAuth (one-click for Drive / Gmail / Calendar) ───────────────────── +// +// Why this exists: the community Google MCP servers each want a gcp-oauth.keys.json file plus a +// one-time `npx … auth` CLI step. That's unacceptable friction for a consumer product. Instead the +// bridge runs Google's OAuth itself — desktop-app authorization-code + PKCE — and stores the +// resulting refresh token in the macOS Keychain (account ":google"). The stdio MCP server is +// then handed a short-lived access token, so the user only ever clicks "Connect" and approves in +// the browser. No files, no terminal. +// +// Ships gated: a Neuron-owned Google OAuth client (GOOGLE_OAUTH_CLIENT_ID / _SECRET) must be +// present, and the app must clear Google's verification for the requested scopes (see +// docs/Tims stuff/google-oauth-verification-checklist.md). Until the client id is configured, +// googleClientConfigured() is false and callers surface an honest "being set up" state rather than +// launching a server that will crash with no credentials. + +const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; + +// The loopback redirect must match exactly what's registered on the Google desktop OAuth client. +export const GOOGLE_REDIRECT_URI = `http://${HOST}:${PORT}/google/callback`; + +export interface GoogleTokens { + refresh_token: string; + access_token?: string; + expires_at?: number; // epoch ms when access_token expires + scope?: string; +} + +function clientId(): string | undefined { + const v = process.env.GOOGLE_OAUTH_CLIENT_ID; + return v && v.trim() ? v.trim() : undefined; +} + +function clientSecret(): string | undefined { + const v = process.env.GOOGLE_OAUTH_CLIENT_SECRET; + return v && v.trim() ? v.trim() : undefined; +} + +// True once the app is shipped with a real Google OAuth client. Gates the whole flow. +export function googleClientConfigured(): boolean { + return clientId() !== undefined; +} + +// In-flight PKCE state, keyed by connector id. Held in memory only for the seconds between +// /google/oauth/start and the browser redirect to /google/callback. +interface PendingFlow { + verifier: string; + scopes: string[]; +} +const pending = new Map(); + +function base64url(buf: Buffer): string { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +// Begin a sign-in: produce the Google consent URL and stash the PKCE verifier for this id. +// Returns undefined if the Google client isn't configured yet (caller surfaces needs_setup). +export function buildGoogleAuthUrl(id: string, scopes: string[]): string | undefined { + const cid = clientId(); + if (!cid) return undefined; + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + pending.set(id, { verifier, scopes }); + const params = new URLSearchParams({ + client_id: cid, + redirect_uri: GOOGLE_REDIRECT_URI, + response_type: "code", + scope: scopes.join(" "), + code_challenge: challenge, + code_challenge_method: "S256", + access_type: "offline", // ask for a refresh token + prompt: "consent", // force refresh-token issuance on re-consent + state: id, // carry the connector id through the round-trip + }); + return `${AUTH_ENDPOINT}?${params.toString()}`; +} + +// Complete a sign-in: exchange the authorization code for tokens and persist the refresh token. +// Throws on any failure (missing flow, Google error) so the caller can report it honestly. +export async function exchangeGoogleCode(id: string, code: string): Promise { + const flow = pending.get(id); + pending.delete(id); + if (!flow) throw new Error(`no pending Google sign-in for ${id}`); + const cid = clientId(); + if (!cid) throw new Error("Google OAuth client not configured"); + + const body = new URLSearchParams({ + client_id: cid, + code, + code_verifier: flow.verifier, + grant_type: "authorization_code", + redirect_uri: GOOGLE_REDIRECT_URI, + }); + // Desktop clients may still carry a (non-secret) client secret; include it when present. + const secret = clientSecret(); + if (secret) body.set("client_secret", secret); + + const resp = await fetch(TOKEN_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + const json = (await resp.json()) as Record; + if (!resp.ok) throw new Error(`Google token exchange failed: ${json.error ?? resp.status} ${json.error_description ?? ""}`.trim()); + if (!json.refresh_token) { + throw new Error("Google did not return a refresh token (revoke prior access and retry with prompt=consent)"); + } + const tokens: GoogleTokens = { + refresh_token: json.refresh_token, + access_token: json.access_token, + expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined, + scope: json.scope, + }; + await kcSetJson(`${id}:google`, tokens); + return tokens; +} + +// Return a currently-valid access token for this connector, refreshing if expired. Returns +// undefined if the user hasn't signed in (no stored refresh token) — caller → needs_auth. +export async function getValidGoogleAccessToken(id: string): Promise { + const stored = await kcGetJson(`${id}:google`); + if (!stored?.refresh_token) return undefined; + // 60s skew so we never hand out a token that's about to expire mid-call. + if (stored.access_token && stored.expires_at && stored.expires_at - 60_000 > nowMs()) { + return stored.access_token; + } + return refreshGoogleToken(id, stored); +} + +async function refreshGoogleToken(id: string, stored: GoogleTokens): Promise { + const cid = clientId(); + if (!cid) return undefined; + const body = new URLSearchParams({ + client_id: cid, + refresh_token: stored.refresh_token, + grant_type: "refresh_token", + }); + const secret = clientSecret(); + if (secret) body.set("client_secret", secret); + + const resp = await fetch(TOKEN_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + const json = (await resp.json()) as Record; + if (!resp.ok || !json.access_token) return undefined; + const updated: GoogleTokens = { + refresh_token: stored.refresh_token, // Google omits it on refresh; keep the original + access_token: json.access_token, + expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined, + scope: json.scope ?? stored.scope, + }; + await kcSetJson(`${id}:google`, updated); + return updated.access_token; +} + +// True once the user has completed sign-in for this connector (a refresh token is stored). +export async function hasGoogleTokens(id: string): Promise { + const stored = await kcGetJson(`${id}:google`); + return !!stored?.refresh_token; +} + +// Forget a connector's Google tokens (on remove / sign-out). +export async function clearGoogleTokens(id: string): Promise { + await kcDelete(`${id}:google`); +} + +// Wrapped so tests/builds without a real clock injection stay simple; Date.now is fine in the +// bridge process (unlike the El soul, this is plain Node). +function nowMs(): number { + return Date.now(); +} diff --git a/src/server.ts b/src/server.ts index 6e18bd0..fa18dd6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -92,6 +92,26 @@ export function startServer(bridge: ConnectorBridge): void { return sendHtml(res, result.ok ? 200 : 400, authResultPage(result.ok, result.error ?? "")); } + // POST /google/oauth/start { id } — begin a Google one-click sign-in, return the consent URL. + if (method === "POST" && url === "/google/oauth/start") { + const parsed = await parseJsonBody<{ id?: string }>(req); + if (!parsed || !parsed.id) return sendJson(res, 400, { ok: false, error: "missing id" }); + const result = await bridge.startGoogleOAuth(parsed.id); + return sendJson(res, result.ok ? 200 : 400, result); + } + + // GET /google/callback?code=..&state=.. — Google redirects here after consent. + if (method === "GET" && url.startsWith("/google/callback")) { + const q = new URL(url, `http://${HOST}:${PORT}`).searchParams; + const id = q.get("state"); // we pass the connector id as `state` + const code = q.get("code"); + const err = q.get("error"); + if (err) return sendHtml(res, 400, authResultPage(false, err)); + if (!id || !code) return sendHtml(res, 400, authResultPage(false, "missing id or code")); + const result = await bridge.finishGoogleOAuth(id, code); + return sendHtml(res, result.ok ? 200 : 400, authResultPage(result.ok, result.error ?? "")); + } + // GET /mcp/auto-approved — the soul reads this to decide which mcp__* calls skip the // approval card (per-connector opt-in, off by default). if (method === "GET" && url === "/mcp/auto-approved") { -- 2.52.0 From fed166202ee71e721c09b2f48467fe97f6c927a1 Mon Sep 17 00:00:00 2001 From: Tim Lingo <1timlingo@gmail.com> Date: Sat, 27 Jun 2026 14:23:20 -0500 Subject: [PATCH 2/5] =?UTF-8?q?feat(connectd):=20@bin/=20=E2=80=94?= =?UTF-8?q?=20spawn=20a=20bundled=20native=20MCP=20server=20binary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @bin/ command resolution alongside @node/@bundled: resolves to a native binary in the bundled-servers dir, spawned directly (no Node runtime). Enables native MCP connectors like the Go whatsapp-mcp. Typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bridge.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/bridge.ts b/src/bridge.ts index 3a88179..3c1ced3 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -32,6 +32,8 @@ export function namespacedName(serverId: string, tool: string): string { // Node we ship in the .app), used to spawn pre-bundled MCP servers. // arg "@bundled/" → a pre-bundled MCP server shipped alongside the bridge (e.g. the // filesystem server for "Your files"), so it runs offline without npx. +// command "@bin/" → a pre-bundled NATIVE MCP server binary shipped alongside the bridge +// (e.g. the Go whatsapp-mcp), spawned directly (no Node runtime). // The bundled-servers dir is the app's resources/.../neuron-connectd/servers (set by the daemons // launcher via NEURON_CONNECTD_BUNDLE_DIR; falls back to a path next to the running bundle). function bundledServersDir(): string { @@ -39,7 +41,10 @@ function bundledServersDir(): string { } function resolveRuntimeCommand(command: string): string { - return command === "@node" ? process.execPath : command; + if (command === "@node") return process.execPath; + // "@bin/": a bundled native binary (e.g. whatsapp-mcp). Resolve to the bundled-servers dir. + if (command.startsWith("@bin/")) return join(bundledServersDir(), command.slice("@bin/".length)); + return command; } function resolveBundledArg(arg: string): string { -- 2.52.0 From edf276c51ed3d55afe110122400eca3be54f166c Mon Sep 17 00:00:00 2001 From: Tim Lingo <1timlingo@gmail.com> Date: Sat, 27 Jun 2026 14:27:28 -0500 Subject: [PATCH 3/5] feat(connectd): companion sidecar lifecycle (connectd-child, v1) Adds optional ServerConfig.companion {command,args,env,healthUrl}. On stdio connect, ensureCompanion spawns the sidecar (e.g. the WhatsApp bridge) if healthUrl isn't already serving, polls health (15s), then spawns the MCP server that talks to it. Killed on closeServer + SIGINT/SIGTERM (disposeCompanions) so it never orphans. Reuses an already-running bridge (idempotent). Typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bridge.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/config.ts | 10 +++++++++ src/index.ts | 8 +++++++ 3 files changed, 79 insertions(+) diff --git a/src/bridge.ts b/src/bridge.ts index 3c1ced3..2964d5c 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -4,6 +4,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; import { createHash } from "node:crypto"; import { join, dirname } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; import type { ConnectorsConfig, ServerConfig } from "./config.js"; import { saveConfig } from "./config.js"; import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js"; @@ -91,6 +92,9 @@ interface ServerEntry { export class ConnectorBridge { private servers = new Map(); + // Companion sidecar processes (e.g. the WhatsApp bridge), keyed by connector id. Started on + // connect, killed on disconnect/shutdown. v1 lifecycle = child-of-connectd (dies with the app). + private companions = new Map(); // The live config the bridge owns and persists. UI edits mutate this and call saveConfig. private config: ConnectorsConfig = { servers: {} }; @@ -166,6 +170,49 @@ export class ConnectorBridge { if (entry?.client) { try { await entry.client.close(); } catch { /* best effort */ } } + const comp = this.companions.get(id); + if (comp) { + try { comp.kill(); } catch { /* best effort */ } + this.companions.delete(id); + } + } + + // Ensure a connector's companion sidecar is running before we spawn its MCP server. + // If healthUrl already responds, something is serving (reuse it). Otherwise spawn the companion + // as a child and poll healthUrl until it's up (or time out). Throws if it never becomes healthy. + private async ensureCompanion(id: string, comp: NonNullable): Promise { + const ping = async (): Promise => { + if (!comp.healthUrl) return false; + try { + const r = await fetch(comp.healthUrl, { signal: AbortSignal.timeout(1500) }); + return r.ok || r.status === 401; // 401 = up but unauthorized (still "running") + } catch { return false; } + }; + if (await ping()) return; // already running (this session or a prior one) + if (this.companions.has(id)) return; // we already started it + const child = spawn( + resolveRuntimeCommand(comp.command), + (comp.args ?? []).map(resolveBundledArg), + { env: { ...process.env, ...(comp.env ?? {}) } as Record, stdio: "ignore" }, + ); + this.companions.set(id, child); + child.on("exit", () => this.companions.delete(id)); + if (!comp.healthUrl) return; // no health probe configured — best-effort start + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 400)); + if (await ping()) return; + } + throw new Error(`companion for "${id}" did not become healthy within 15s`); + } + + // Kill every companion sidecar — called on connectd shutdown so we never orphan a child + // process (e.g. the WhatsApp bridge) when the app/daemon exits. + disposeCompanions(): void { + for (const [id, child] of this.companions) { + try { child.kill(); } catch { /* best effort */ } + this.companions.delete(id); + } } private async connectServer(id: string, cfg: ServerConfig): Promise { @@ -204,6 +251,20 @@ export class ConnectorBridge { // not a code change — we set the common names so swapping servers stays config-only. extraEnv = { GOOGLE_ACCESS_TOKEN: token, GOOGLE_OAUTH_ACCESS_TOKEN: token }; } + // Start the companion sidecar (e.g. the WhatsApp bridge) before the MCP server that talks to it. + if (cfg.companion) { + try { + await this.ensureCompanion(id, cfg.companion); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.servers.set(id, { + id, cfg, status: "needs_setup", tools: [], + error: "Couldn't start the connector's helper service — try toggling it off and on.", + }); + console.error(`[connectd] ${id}: companion failed (${msg}) — needs_setup`); + return; + } + } try { const transport = new StdioClientTransport({ command: resolveRuntimeCommand(cfg.command!), diff --git a/src/config.ts b/src/config.ts index fe2f39c..c8817c0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,16 @@ export interface ServerConfig { command?: string; args?: string[]; env?: Record; + // Companion sidecar: a long-running process this connector's MCP server talks to (e.g. the + // WhatsApp bridge on :8080 that holds the paired session). connectd starts it as a CHILD when the + // connector connects, waits for healthUrl to respond, then spawns the MCP server; it is killed on + // disconnect/shutdown. `command` supports @bin//@node//@bundled resolution like the server itself. + companion?: { + command: string; + args?: string[]; + env?: Record; + healthUrl?: string; + }; // http (Phase 3) url?: string; auth?: "none" | "oauth" | "token" | "google"; diff --git a/src/index.ts b/src/index.ts index a8ea66b..cd76574 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,14 @@ async function main(): Promise { console.error(`[connectd] ready — ${total} tool(s) across ${bridge.serverStatuses().length} server(s)`); startServer(bridge); + + // Clean shutdown: kill companion sidecars (e.g. the WhatsApp bridge) so they don't orphan. + for (const sig of ["SIGINT", "SIGTERM"] as const) { + process.on(sig, () => { + bridge.disposeCompanions(); + process.exit(0); + }); + } } main().catch((err) => { -- 2.52.0 From 995b9a8548c1d99ed20ba04c57bd3840dfa46b27 Mon Sep 17 00:00:00 2001 From: Tim Lingo <1timlingo@gmail.com> Date: Sat, 27 Jun 2026 14:56:38 -0500 Subject: [PATCH 4/5] feat(connectd): companion cwd (writable session dir for the WhatsApp bridge) companion.cwd (created if missing; ~/$HOME expanded) sets the sidecar's working directory so the WhatsApp bridge writes its session store to a writable path (~/.neuron/whatsapp), never the read-only app bundle. Typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bridge.ts | 9 ++++++++- src/config.ts | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/bridge.ts b/src/bridge.ts index 2964d5c..390766a 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -5,6 +5,8 @@ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; import { createHash } from "node:crypto"; import { join, dirname } from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; import type { ConnectorsConfig, ServerConfig } from "./config.js"; import { saveConfig } from "./config.js"; import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js"; @@ -190,10 +192,15 @@ export class ConnectorBridge { }; if (await ping()) return; // already running (this session or a prior one) if (this.companions.has(id)) return; // we already started it + let cwd: string | undefined; + if (comp.cwd) { + cwd = comp.cwd.replace(/^~(?=\/|$)/, homedir()).replace(/^\$HOME(?=\/|$)/, homedir()); + try { mkdirSync(cwd, { recursive: true }); } catch { /* best effort — spawn will surface a real failure */ } + } const child = spawn( resolveRuntimeCommand(comp.command), (comp.args ?? []).map(resolveBundledArg), - { env: { ...process.env, ...(comp.env ?? {}) } as Record, stdio: "ignore" }, + { env: { ...process.env, ...(comp.env ?? {}) } as Record, stdio: "ignore", cwd }, ); this.companions.set(id, child); child.on("exit", () => this.companions.delete(id)); diff --git a/src/config.ts b/src/config.ts index c8817c0..0e3f7d1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,10 @@ export interface ServerConfig { args?: string[]; env?: Record; healthUrl?: string; + // Working directory for the sidecar (created if missing). The WhatsApp bridge writes its session + // store relative to cwd, so this must be writable (e.g. ~/.neuron/whatsapp) — never the read-only + // app bundle. "~" / "$HOME" at the start are expanded to the user's home. + cwd?: string; }; // http (Phase 3) url?: string; -- 2.52.0 From b3f3944c957c81e706096d6cec4340c57ac4bb63 Mon Sep 17 00:00:00 2001 From: Tim Lingo <1timlingo@gmail.com> Date: Fri, 17 Jul 2026 10:31:14 -0500 Subject: [PATCH 5/5] =?UTF-8?q?fix(build):=20sync=20package-lock=20with=20?= =?UTF-8?q?a012367's=20added=20deps=20=E2=80=94=20npm=20ci=20was=20failing?= =?UTF-8?q?=20for=20every=20clean=20installer=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- package-lock.json | 472 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 471 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 76a84d7..5beb2d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,15 @@ "name": "neuron-connectd", "version": "0.1.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server-filesystem": "^2026.1.14" }, "bin": { "neuron-connectd": "dist/index.js" }, "devDependencies": { "@types/node": "^24.0.0", + "esbuild": "^0.28.0", "tsx": "^4.19.0", "typescript": "^5.6.0" } @@ -473,6 +475,23 @@ "hono": "^4" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -513,6 +532,31 @@ } } }, + "node_modules/@modelcontextprotocol/server-filesystem": { + "version": "2026.7.10", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-filesystem/-/server-filesystem-2026.7.10.tgz", + "integrity": "sha512-Mmjg4anFBD5OzbPnGJOA0jPPN8645ERhQk38HQLpSenx1ox9bfdPkmAzUnNjeQtqQGFLtKe13J20RtLBmUKMZA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "diff": "^8.0.3", + "glob": "^10.5.0", + "minimatch": "^10.0.1" + }, + "bin": { + "mcp-server-filesystem": "dist/index.js" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@types/node": { "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", @@ -569,6 +613,39 @@ } } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -593,6 +670,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -631,6 +720,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -728,6 +835,15 @@ "node": ">= 0.8" } }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -742,12 +858,24 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -969,6 +1097,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1048,6 +1192,57 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1153,6 +1348,15 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -1165,6 +1369,21 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -1186,6 +1405,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1241,6 +1466,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1298,6 +1547,12 @@ "wrappy": "1" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1316,6 +1571,22 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -1562,6 +1833,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1571,6 +1854,102 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1684,6 +2063,97 @@ "node": ">= 8" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", -- 2.52.0