Introduce a multi-step add-source page: enter a URL, auto-detect source type via the discover API, configure auth, then connect. Extract source templates into a standalone module and add new GraphQL templates. Add favicon components for source endpoints. Refactor the source editor for decoupled OAuth flow and GraphQL kind support. Improve dev server error logging.
Unified · Split
| @@ -22,7 +22,8 @@ · 21 unmodified lines | ||
| 22 | 22 | "scheduler": "^0.27.0", |
| 23 | 23 | "shiki": "^4.0.1", |
| 24 | 24 | "streamdown": "^2.3.0", |
| 25 | − "tailwind-merge": "^3.5.0" | |
| 25 | + "tailwind-merge": "^3.5.0", | |
| 26 | + "tldts": "^7.0.23" | |
| 26 | 27 | }, |
| 27 | 28 | "devDependencies": { |
| 28 | 29 | "@hono/vite-dev-server": "^0.25.0", |
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | import { Link, Outlet, useMatchRoute } from "@tanstack/react-router"; |
| 2 | 2 | import { useSources, type Source } from "@executor-v3/react"; |
| 3 | 3 | import { cn } from "../lib/utils"; |
| 4 | −import { IconPlus, IconSources } from "./icons"; | |
| 4 | +import { IconPlus } from "./icons"; | |
| 5 | 5 | import { LoadableBlock } from "./loadable"; |
| 6 | +import { SourceFavicon } from "./source-favicon"; | |
| 6 | 7 | |
| 7 | 8 | // ── Status dot color ───────────────────────────────────────────────────── |
| 8 | 9 | |
| @@ -47,7 +48,7 @@ export function AppShell() { · 38 unmodified lines | ||
| 47 | 48 | <div className="flex items-center justify-between gap-2"> |
| 48 | 49 | <span>Sources</span> |
| 49 | 50 | <Link |
| 50 | − to="/sources/new" | |
| 51 | + to="/sources/add" | |
| 51 | 52 | className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[10px] font-medium normal-case tracking-normal text-primary transition-colors hover:bg-sidebar-active hover:text-foreground" |
| 52 | 53 | > |
| 53 | 54 | <IconPlus className="size-3" /> |
| @@ -113,7 +114,9 @@ function SourceItem(props: { · 59 unmodified lines | ||
| 113 | 114 | : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", |
| 114 | 115 | )} |
| 115 | 116 | > |
| 116 | − <IconSources className="size-3 shrink-0 text-muted-foreground/40" /> | |
| 117 | + <div className="flex size-3 shrink-0 items-center justify-center text-muted-foreground/50"> | |
| 118 | + <SourceFavicon endpoint={source.endpoint} kind={source.kind} className="size-3" /> | |
| 119 | + </div> | |
| 117 | 120 | <span className="flex-1 truncate">{source.name}</span> |
| 118 | 121 | <span |
| 119 | 122 | className={cn("size-1.5 shrink-0 rounded-full", statusColor[source.status] ?? "bg-muted-foreground/30")} |
| @@ -0,0 +1,81 @@ | ||
| 1 | +import { useMemo, useState } from "react"; | |
| 2 | +import type { Source } from "@executor-v3/react"; | |
| 3 | +import { getSourceFaviconUrl } from "../lib/source-favicon"; | |
| 4 | +import { cn } from "../lib/utils"; | |
| 5 | + | |
| 6 | +type SourceKind = Source["kind"] | string; | |
| 7 | + | |
| 8 | +export function SourceFavicon({ | |
| 9 | + endpoint, | |
| 10 | + kind, | |
| 11 | + className, | |
| 12 | + size = 16, | |
| 13 | +}: { | |
| 14 | + endpoint?: string | null; | |
| 15 | + kind: SourceKind; | |
| 16 | + className?: string; | |
| 17 | + size?: number; | |
| 18 | +}) { | |
| 19 | + const faviconUrl = useMemo(() => getSourceFaviconUrl(endpoint), [endpoint]); | |
| 20 | + const [failedUrl, setFailedUrl] = useState<string | null>(null); | |
| 21 | + const isFailed = Boolean(faviconUrl && failedUrl === faviconUrl); | |
| 22 | + | |
| 23 | + if (!faviconUrl || isFailed) { | |
| 24 | + return <DefaultSourceIcon kind={kind} className={className} />; | |
| 25 | + } | |
| 26 | + | |
| 27 | + return ( | |
| 28 | + <img | |
| 29 | + key={faviconUrl} | |
| 30 | + src={faviconUrl} | |
| 31 | + alt="" | |
| 32 | + width={size} | |
| 33 | + height={size} | |
| 34 | + className={cn("size-full rounded-full object-cover", className)} | |
| 35 | + loading="lazy" | |
| 36 | + referrerPolicy="no-referrer" | |
| 37 | + onError={() => setFailedUrl(faviconUrl)} | |
| 38 | + /> | |
| 39 | + ); | |
| 40 | +} | |
| 41 | + | |
| 42 | +export function DefaultSourceIcon({ | |
| 43 | + kind, | |
| 44 | + className, | |
| 45 | +}: { | |
| 46 | + kind: SourceKind; | |
| 47 | + className?: string; | |
| 48 | +}) { | |
| 49 | + const base = cn("shrink-0", className); | |
| 50 | + | |
| 51 | + switch (kind) { | |
| 52 | + case "mcp": | |
| 53 | + return ( | |
| 54 | + <svg viewBox="0 0 16 16" fill="none" className={base}> | |
| 55 | + <rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.2" /> | |
| 56 | + <path d="M5 7h1M5 9h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> | |
| 57 | + </svg> | |
| 58 | + ); | |
| 59 | + case "graphql": | |
| 60 | + return ( | |
| 61 | + <svg viewBox="0 0 16 16" fill="none" className={base}> | |
| 62 | + <path d="M8 2L14 5.5V10.5L8 14L2 10.5V5.5L8 2Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" /> | |
| 63 | + <circle cx="8" cy="8" r="1.5" fill="currentColor" opacity="0.5" /> | |
| 64 | + </svg> | |
| 65 | + ); | |
| 66 | + case "openapi": | |
| 67 | + return ( | |
| 68 | + <svg viewBox="0 0 16 16" fill="none" className={base}> | |
| 69 | + <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" /> | |
| 70 | + <path d="M5 6h6M5 8h4M5 10h5" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" /> | |
| 71 | + </svg> | |
| 72 | + ); | |
| 73 | + default: | |
| 74 | + return ( | |
| 75 | + <svg viewBox="0 0 16 16" fill="none" className={base}> | |
| 76 | + <rect x="2.5" y="2.5" width="11" height="11" rx="2" stroke="currentColor" strokeWidth="1.2" /> | |
| 77 | + <path d="M5 6h6M5 8h4M5 10h3" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" /> | |
| 78 | + </svg> | |
| 79 | + ); | |
| 80 | + } | |
| 81 | +} | |
| @@ -9,6 +9,22 @@ import * as Effect from "effect/Effect"; · 8 unmodified lines | ||
| 9 | 9 | import * as Scope from "effect/Scope"; |
| 10 | 10 | import { createLocalExecutorRequestHandler } from "@executor-v3/server"; |
| 11 | 11 | |
| 12 | +const MAX_LOGGED_ERROR_BODY_LENGTH = 4_000; | |
| 13 | + | |
| 14 | +const truncateForLog = (value: string): string => | |
| 15 | + value.length > MAX_LOGGED_ERROR_BODY_LENGTH | |
| 16 | + ? `${value.slice(0, MAX_LOGGED_ERROR_BODY_LENGTH)}... [truncated]` | |
| 17 | + : value; | |
| 18 | + | |
| 19 | +const formatErrorForLog = (error: unknown) => | |
| 20 | + error instanceof Error | |
| 21 | + ? { | |
| 22 | + name: error.name, | |
| 23 | + message: error.message, | |
| 24 | + stack: error.stack, | |
| 25 | + } | |
| 26 | + : error; | |
| 27 | + | |
| 12 | 28 | // Create a long-lived scope that stays open for the lifetime of the process. |
| 13 | 29 | const handlerPromise = Effect.runPromise( |
| 14 | 30 | Effect.gen(function* () { |
| @@ -16,14 +32,46 @@ const handlerPromise = Effect.runPromise( · 1 unmodified line | ||
| 16 | 32 | const handler = yield* createLocalExecutorRequestHandler().pipe( |
| 17 | 33 | Effect.provideService(Scope.Scope, scope), |
| 18 | 34 | ); |
| 19 | − handler.setBaseUrl("http://127.0.0.1:8788"); | |
| 20 | 35 | return handler; |
| 21 | 36 | }), |
| 22 | −); | |
| 37 | +).catch((error) => { | |
| 38 | + console.error("[executor dev api] failed to initialize request handler", formatErrorForLog(error)); | |
| 39 | + throw error; | |
| 40 | +}); | |
| 23 | 41 | |
| 24 | 42 | export default { |
| 25 | 43 | async fetch(request: Request) { |
| 26 | − const handler = await handlerPromise; | |
| 27 | − return handler.handleApiRequest(request); | |
| 44 | + const url = new URL(request.url); | |
| 45 | + | |
| 46 | + try { | |
| 47 | + const handler = await handlerPromise; | |
| 48 | + handler.setBaseUrl(url.origin); | |
| 49 | + | |
| 50 | + const response = await handler.handleApiRequest(request); | |
| 51 | + | |
| 52 | + if (url.pathname.startsWith("/v1/") && response.status >= 500) { | |
| 53 | + let bodyText = "<unavailable>"; | |
| 54 | + try { | |
| 55 | + bodyText = truncateForLog(await response.clone().text()); | |
| 56 | + } catch {} | |
| 57 | + | |
| 58 | + console.error("[executor dev api] request failed", { | |
| 59 | + method: request.method, | |
| 60 | + url: url.toString(), | |
| 61 | + status: response.status, | |
| 62 | + contentType: response.headers.get("content-type"), | |
| 63 | + body: bodyText, | |
| 64 | + }); | |
| 65 | + } | |
| 66 | + | |
| 67 | + return response; | |
| 68 | + } catch (error) { | |
| 69 | + console.error("[executor dev api] unhandled request error", { | |
| 70 | + method: request.method, | |
| 71 | + url: url.toString(), | |
| 72 | + error: formatErrorForLog(error), | |
| 73 | + }); | |
| 74 | + throw error; | |
| 75 | + } | |
| 28 | 76 | }, |
| 29 | 77 | }; |
| @@ -0,0 +1,63 @@ | ||
| 1 | +import { parse as parseDomain } from "tldts"; | |
| 2 | + | |
| 3 | +const RAW_HOSTS = new Set([ | |
| 4 | + "raw.githubusercontent.com", | |
| 5 | + "cdn.jsdelivr.net", | |
| 6 | + "unpkg.com", | |
| 7 | + "raw.github.com", | |
| 8 | +]); | |
| 9 | + | |
| 10 | +const parseUrl = (value: string | null | undefined): URL | null => { | |
| 11 | + if (!value) { | |
| 12 | + return null; | |
| 13 | + } | |
| 14 | + | |
| 15 | + try { | |
| 16 | + return new URL(value); | |
| 17 | + } catch { | |
| 18 | + return null; | |
| 19 | + } | |
| 20 | +}; | |
| 21 | + | |
| 22 | +const normalizeSeedUrl = (value: string | null | undefined): string | null => { | |
| 23 | + const parsed = parseUrl(value); | |
| 24 | + if (!parsed) { | |
| 25 | + return null; | |
| 26 | + } | |
| 27 | + | |
| 28 | + if (!RAW_HOSTS.has(parsed.hostname)) { | |
| 29 | + return parsed.origin; | |
| 30 | + } | |
| 31 | + | |
| 32 | + const segments = parsed.pathname | |
| 33 | + .split("/") | |
| 34 | + .map((segment) => segment.trim()) | |
| 35 | + .filter((segment) => segment.length > 0); | |
| 36 | + | |
| 37 | + for (const segment of segments) { | |
| 38 | + const withoutExtension = segment.replace(/\.(ya?ml|json)$/i, ""); | |
| 39 | + if (/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(withoutExtension)) { | |
| 40 | + return `https://${withoutExtension}`; | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + return parsed.origin; | |
| 45 | +}; | |
| 46 | + | |
| 47 | +export const getSourceFaviconUrl = ( | |
| 48 | + value: string | null | undefined, | |
| 49 | +): string | null => { | |
| 50 | + const seedUrl = normalizeSeedUrl(value); | |
| 51 | + if (!seedUrl) { | |
| 52 | + return null; | |
| 53 | + } | |
| 54 | + | |
| 55 | + try { | |
| 56 | + const hostname = new URL(seedUrl).hostname; | |
| 57 | + const parsed = parseDomain(hostname); | |
| 58 | + const domain = parsed.domain ?? hostname; | |
| 59 | + return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`; | |
| 60 | + } catch { | |
| 61 | + return null; | |
| 62 | + } | |
| 63 | +}; | |
| @@ -15,6 +15,7 @@ import { HomePage } from "./views/home"; · 14 unmodified lines | ||
| 15 | 15 | import { EditSourcePage, NewSourcePage } from "./views/source-editor"; |
| 16 | 16 | import { SourceDetailPage } from "./views/source-detail"; |
| 17 | 17 | import { SecretsPage } from "./views/secrets"; |
| 18 | +import { AddSourcePage } from "./views/add-source"; | |
| 18 | 19 | |
| 19 | 20 | // --------------------------------------------------------------------------- |
| 20 | 21 | // Route search schema |
| @@ -48,6 +49,12 @@ const newSourceRoute = createRoute({ · 27 unmodified lines | ||
| 48 | 49 | component: NewSourcePage, |
| 49 | 50 | }); |
| 50 | 51 | |
| 52 | +const addSourceRoute = createRoute({ | |
| 53 | + getParentRoute: () => rootRoute, | |
| 54 | + path: "/sources/add", | |
| 55 | + component: AddSourcePage, | |
| 56 | +}); | |
| 57 | + | |
| 51 | 58 | const sourceRoute = createRoute({ |
| 52 | 59 | getParentRoute: () => rootRoute, |
| 53 | 60 | path: "/sources/$sourceId", |
| @@ -98,7 +105,7 @@ function EditSourcePageWrapper() { · 44 unmodified lines | ||
| 98 | 105 | // Router |
| 99 | 106 | // --------------------------------------------------------------------------- |
| 100 | 107 | |
| 101 | −const routeTree = rootRoute.addChildren([homeRoute, newSourceRoute, sourceRoute, editSourceRoute, secretsRoute]); | |
| 108 | +const routeTree = rootRoute.addChildren([homeRoute, newSourceRoute, addSourceRoute, sourceRoute, editSourceRoute, secretsRoute]); | |
| 102 | 109 | |
| 103 | 110 | const router = createRouter({ |
| 104 | 111 | routeTree, |
| @@ -0,0 +1,1360 @@ | ||
| 1 | +import { useState, type ReactNode } from "react"; | |
| 2 | +import { Link, useNavigate } from "@tanstack/react-router"; | |
| 3 | +import { | |
| 4 | + type CompleteSourceOAuthResult, | |
| 5 | + type ConnectSourcePayload, | |
| 6 | + type ConnectSourceResult, | |
| 7 | + type DiscoverSourcePayload, | |
| 8 | + type Loadable, | |
| 9 | + type SecretListItem, | |
| 10 | + type Source, | |
| 11 | + type SourceDiscoveryResult, | |
| 12 | + useConnectSource, | |
| 13 | + useCreateSecret, | |
| 14 | + useDiscoverSource, | |
| 15 | + useRefreshSecrets, | |
| 16 | + useSecrets, | |
| 17 | + useStartSourceOAuth, | |
| 18 | +} from "@executor-v3/react"; | |
| 19 | +import { Badge } from "../components/ui/badge"; | |
| 20 | +import { Button } from "../components/ui/button"; | |
| 21 | +import { SourceFavicon } from "../components/source-favicon"; | |
| 22 | +import { | |
| 23 | + IconArrowLeft, | |
| 24 | + IconCheck, | |
| 25 | + IconDiscover, | |
| 26 | + IconPlus, | |
| 27 | + IconSpinner, | |
| 28 | +} from "../components/icons"; | |
| 29 | +import { cn } from "../lib/utils"; | |
| 30 | +import { sourceTemplates, type SourceTemplate } from "./source-templates"; | |
| 31 | +import { getDomain } from "tldts"; | |
| 32 | + | |
| 33 | +// --------------------------------------------------------------------------- | |
| 34 | +// State machine | |
| 35 | +// --------------------------------------------------------------------------- | |
| 36 | + | |
| 37 | +type FlowPhase = | |
| 38 | + | "idle" | |
| 39 | + | "discovering" | |
| 40 | + | "editing" | |
| 41 | + | "connecting" | |
| 42 | + | "connected" | |
| 43 | + | "credential_required" | |
| 44 | + | "oauth_required"; | |
| 45 | + | |
| 46 | +type ProbeAuthKind = "none" | "bearer" | "basic" | "headers"; | |
| 47 | + | |
| 48 | +type ProbeAuthState = { | |
| 49 | + kind: ProbeAuthKind; | |
| 50 | + token: string; | |
| 51 | + headerName: string; | |
| 52 | + prefix: string; | |
| 53 | + username: string; | |
| 54 | + password: string; | |
| 55 | + headersText: string; | |
| 56 | +}; | |
| 57 | + | |
| 58 | +type ConnectFormState = { | |
| 59 | + kind: "mcp" | "openapi" | "graphql"; | |
| 60 | + endpoint: string; | |
| 61 | + specUrl: string; | |
| 62 | + name: string; | |
| 63 | + namespace: string; | |
| 64 | + transport: "" | "auto" | "streamable-http" | "sse"; | |
| 65 | + authKind: "none" | "bearer" | "oauth2"; | |
| 66 | + authHeaderName: string; | |
| 67 | + authPrefix: string; | |
| 68 | + bearerToken: string; | |
| 69 | + bearerProviderId: string; | |
| 70 | + bearerHandle: string; | |
| 71 | + queryParamsText: string; | |
| 72 | + headersText: string; | |
| 73 | +}; | |
| 74 | + | |
| 75 | +type OAuthRequiredInfo = { | |
| 76 | + source: Source; | |
| 77 | + sessionId: string; | |
| 78 | + authorizationUrl: string; | |
| 79 | +}; | |
| 80 | + | |
| 81 | +// --------------------------------------------------------------------------- | |
| 82 | +// Constants | |
| 83 | +// --------------------------------------------------------------------------- | |
| 84 | + | |
| 85 | +const kindOptions: ReadonlyArray<ConnectFormState["kind"]> = ["mcp", "openapi", "graphql"]; | |
| 86 | + | |
| 87 | +const transportOptions: ReadonlyArray<NonNullable<Source["transport"]>> = [ | |
| 88 | + "auto", | |
| 89 | + "streamable-http", | |
| 90 | + "sse", | |
| 91 | +]; | |
| 92 | + | |
| 93 | +const authOptions: ReadonlyArray<ConnectFormState["authKind"]> = ["none", "bearer", "oauth2"]; | |
| 94 | + | |
| 95 | +const probeAuthOptions: ReadonlyArray<ProbeAuthKind> = ["none", "bearer", "basic", "headers"]; | |
| 96 | + | |
| 97 | +const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; | |
| 98 | +const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor-v3:oauth-result:"; | |
| 99 | + | |
| 100 | +// --------------------------------------------------------------------------- | |
| 101 | +// Helpers | |
| 102 | +// --------------------------------------------------------------------------- | |
| 103 | + | |
| 104 | +const trimToNull = (value: string): string | null => { | |
| 105 | + const trimmed = value.trim(); | |
| 106 | + return trimmed.length > 0 ? trimmed : null; | |
| 107 | +}; | |
| 108 | + | |
| 109 | +const namespaceFromUrl = (url: string): string => { | |
| 110 | + try { | |
| 111 | + const domain = getDomain(url); | |
| 112 | + if (!domain) return ""; | |
| 113 | + // Strip the TLD: "github.com" -> "github", "linear.app" -> "linear" | |
| 114 | + const dot = domain.indexOf("."); | |
| 115 | + return dot > 0 ? domain.slice(0, dot) : domain; | |
| 116 | + } catch { | |
| 117 | + return ""; | |
| 118 | + } | |
| 119 | +}; | |
| 120 | + | |
| 121 | +const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => { | |
| 122 | + const trimmed = text.trim(); | |
| 123 | + if (trimmed.length === 0) { | |
| 124 | + return null; | |
| 125 | + } | |
| 126 | + | |
| 127 | + let parsed: unknown; | |
| 128 | + try { | |
| 129 | + parsed = JSON.parse(trimmed); | |
| 130 | + } catch { | |
| 131 | + throw new Error(`${label} must be valid JSON.`); | |
| 132 | + } | |
| 133 | + | |
| 134 | + if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") { | |
| 135 | + throw new Error(`${label} must be a JSON object with string values.`); | |
| 136 | + } | |
| 137 | + | |
| 138 | + const entries = Object.entries(parsed as Record<string, unknown>); | |
| 139 | + const normalized: Record<string, string> = {}; | |
| 140 | + for (const [key, value] of entries) { | |
| 141 | + if (typeof value !== "string") { | |
| 142 | + throw new Error(`${label} must only contain string values.`); | |
| 143 | + } | |
| 144 | + normalized[key] = value; | |
| 145 | + } | |
| 146 | + | |
| 147 | + return Object.keys(normalized).length === 0 ? null : normalized; | |
| 148 | +}; | |
| 149 | + | |
| 150 | +// --------------------------------------------------------------------------- | |
| 151 | +// Derived defaults from discovery result | |
| 152 | +// --------------------------------------------------------------------------- | |
| 153 | + | |
| 154 | +const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState => { | |
| 155 | + if (!discovery || discovery.detectedKind === "unknown") { | |
| 156 | + return { | |
| 157 | + kind: "openapi", | |
| 158 | + endpoint: discovery?.endpoint ?? "", | |
| 159 | + specUrl: discovery?.specUrl ?? "", | |
| 160 | + name: discovery?.name ?? "", | |
| 161 | + namespace: discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""), | |
| 162 | + transport: "", | |
| 163 | + authKind: "none", | |
| 164 | + authHeaderName: "Authorization", | |
| 165 | + authPrefix: "Bearer ", | |
| 166 | + bearerToken: "", | |
| 167 | + bearerProviderId: "", | |
| 168 | + bearerHandle: "", | |
| 169 | + queryParamsText: "", | |
| 170 | + headersText: "", | |
| 171 | + }; | |
| 172 | + } | |
| 173 | + | |
| 174 | + const kind = discovery.detectedKind as ConnectFormState["kind"]; | |
| 175 | + const auth = discovery.authInference; | |
| 176 | + | |
| 177 | + // Map auth suggestion to what backend connect supports | |
| 178 | + let authKind: ConnectFormState["authKind"] = "none"; | |
| 179 | + let authHeaderName = "Authorization"; | |
| 180 | + let authPrefix = "Bearer "; | |
| 181 | + | |
| 182 | + if (auth.supported) { | |
| 183 | + if (auth.suggestedKind === "bearer" || auth.suggestedKind === "apiKey") { | |
| 184 | + authKind = "bearer"; | |
| 185 | + authHeaderName = auth.headerName ?? "Authorization"; | |
| 186 | + authPrefix = auth.prefix ?? "Bearer "; | |
| 187 | + } else if (auth.suggestedKind === "oauth2") { | |
| 188 | + authKind = "oauth2"; | |
| 189 | + authHeaderName = auth.headerName ?? "Authorization"; | |
| 190 | + authPrefix = auth.prefix ?? "Bearer "; | |
| 191 | + } else if (auth.suggestedKind === "basic") { | |
| 192 | + // Backend connect doesn't support basic auth natively; map to bearer | |
| 193 | + authKind = "bearer"; | |
| 194 | + authHeaderName = "Authorization"; | |
| 195 | + authPrefix = "Basic "; | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + return { | |
| 200 | + kind, | |
| 201 | + endpoint: discovery.endpoint, | |
| 202 | + specUrl: discovery.specUrl ?? "", | |
| 203 | + name: discovery.name ?? "", | |
| 204 | + namespace: discovery.namespace || namespaceFromUrl(discovery.endpoint), | |
| 205 | + transport: kind === "mcp" ? (discovery.transport ?? "auto") : "", | |
| 206 | + authKind, | |
| 207 | + authHeaderName, | |
| 208 | + authPrefix, | |
| 209 | + bearerToken: "", | |
| 210 | + bearerProviderId: "", | |
| 211 | + bearerHandle: "", | |
| 212 | + queryParamsText: "", | |
| 213 | + headersText: "", | |
| 214 | + }; | |
| 215 | +}; | |
| 216 | + | |
| 217 | +const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => ({ | |
| 218 | + ...defaultConnectForm(), | |
| 219 | + kind: template.kind === "internal" ? "openapi" : template.kind, | |
| 220 | + endpoint: template.endpoint, | |
| 221 | + name: template.name, | |
| 222 | + namespace: namespaceFromUrl(template.endpoint), | |
| 223 | + transport: template.kind === "mcp" ? "auto" : "", | |
| 224 | +}); | |
| 225 | + | |
| 226 | +const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth"] => { | |
| 227 | + if (state.kind === "none") return { kind: "none" }; | |
| 228 | + if (state.kind === "bearer") { | |
| 229 | + if (!state.token.trim()) throw new Error("Token is required for bearer probe auth."); | |
| 230 | + return { | |
| 231 | + kind: "bearer", | |
| 232 | + headerName: trimToNull(state.headerName), | |
| 233 | + prefix: trimToNull(state.prefix), | |
| 234 | + token: state.token.trim(), | |
| 235 | + }; | |
| 236 | + } | |
| 237 | + if (state.kind === "basic") { | |
| 238 | + if (!state.username.trim()) throw new Error("Username is required for basic probe auth."); | |
| 239 | + return { | |
| 240 | + kind: "basic", | |
| 241 | + username: state.username.trim(), | |
| 242 | + password: state.password, | |
| 243 | + }; | |
| 244 | + } | |
| 245 | + // headers | |
| 246 | + const headers = parseJsonStringMap("Probe headers", state.headersText); | |
| 247 | + if (!headers) throw new Error("At least one header is required for headers probe auth."); | |
| 248 | + return { kind: "headers", headers }; | |
| 249 | +}; | |
| 250 | + | |
| 251 | +const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { | |
| 252 | + const endpoint = form.endpoint.trim(); | |
| 253 | + if (!endpoint) throw new Error("Endpoint is required."); | |
| 254 | + | |
| 255 | + if (form.kind === "mcp") { | |
| 256 | + return { | |
| 257 | + kind: "mcp", | |
| 258 | + endpoint, | |
| 259 | + name: trimToNull(form.name), | |
| 260 | + namespace: trimToNull(form.namespace), | |
| 261 | + transport: form.transport === "" ? "auto" : form.transport, | |
| 262 | + queryParams: parseJsonStringMap("Query params", form.queryParamsText), | |
| 263 | + headers: parseJsonStringMap("Request headers", form.headersText), | |
| 264 | + }; | |
| 265 | + } | |
| 266 | + | |
| 267 | + // Build HTTP auth for openapi/graphql | |
| 268 | + const auth = buildHttpAuth(form); | |
| 269 | + | |
| 270 | + if (form.kind === "openapi") { | |
| 271 | + const specUrl = form.specUrl.trim(); | |
| 272 | + if (!specUrl) throw new Error("OpenAPI sources require a spec URL."); | |
| 273 | + return { | |
| 274 | + kind: "openapi", | |
| 275 | + endpoint, | |
| 276 | + specUrl, | |
| 277 | + name: trimToNull(form.name), | |
| 278 | + namespace: trimToNull(form.namespace), | |
| 279 | + auth, | |
| 280 | + }; | |
| 281 | + } | |
| 282 | + | |
| 283 | + return { | |
| 284 | + kind: "graphql", | |
| 285 | + endpoint, | |
| 286 | + name: trimToNull(form.name), | |
| 287 | + namespace: trimToNull(form.namespace), | |
| 288 | + auth, | |
| 289 | + }; | |
| 290 | +}; | |
| 291 | + | |
| 292 | +const buildHttpAuth = ( | |
| 293 | + form: ConnectFormState, | |
| 294 | +): { kind: "none" } | { kind: "bearer"; headerName?: string | null; prefix?: string | null; token?: string | null; tokenRef?: { providerId: string; handle: string } | null } | undefined => { | |
| 295 | + if (form.authKind === "none") return { kind: "none" }; | |
| 296 | + | |
| 297 | + if (form.authKind === "bearer") { | |
| 298 | + const headerName = trimToNull(form.authHeaderName); | |
| 299 | + const prefix = form.authPrefix.length === 0 ? null : form.authPrefix; | |
| 300 | + | |
| 301 | + // Prefer secret ref if set | |
| 302 | + if (form.bearerProviderId.trim() && form.bearerHandle.trim()) { | |
| 303 | + return { | |
| 304 | + kind: "bearer", | |
| 305 | + headerName, | |
| 306 | + prefix, | |
| 307 | + tokenRef: { | |
| 308 | + providerId: form.bearerProviderId.trim(), | |
| 309 | + handle: form.bearerHandle.trim(), | |
| 310 | + }, | |
| 311 | + }; | |
| 312 | + } | |
| 313 | + | |
| 314 | + // Fall back to inline token | |
| 315 | + if (form.bearerToken.trim()) { | |
| 316 | + return { | |
| 317 | + kind: "bearer", | |
| 318 | + headerName, | |
| 319 | + prefix, | |
| 320 | + token: form.bearerToken.trim(), | |
| 321 | + }; | |
| 322 | + } | |
| 323 | + | |
| 324 | + throw new Error("Bearer auth requires a token. Select or create a secret, or enter a token directly."); | |
| 325 | + } | |
| 326 | + | |
| 327 | + // oauth2 is handled via the connect result flow, not pre-filled | |
| 328 | + return undefined; | |
| 329 | +}; | |
| 330 | + | |
| 331 | +// --------------------------------------------------------------------------- | |
| 332 | +// OAuth popup helpers (shared with source-editor.tsx) | |
| 333 | +// --------------------------------------------------------------------------- | |
| 334 | + | |
| 335 | +type SourceOAuthPopupMessage = | |
| 336 | + | { | |
| 337 | + type: "executor-v3:oauth-result"; | |
| 338 | + ok: true; | |
| 339 | + sessionId: string; | |
| 340 | + auth: CompleteSourceOAuthResult["auth"]; | |
| 341 | + } | |
| 342 | + | { | |
| 343 | + type: "executor-v3:oauth-result"; | |
| 344 | + ok: false; | |
| 345 | + sessionId: string | null; | |
| 346 | + error: string; | |
| 347 | + }; | |
| 348 | + | |
| 349 | +const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => { | |
| 350 | + if (typeof window === "undefined") return null; | |
| 351 | + const raw = window.localStorage.getItem( | |
| 352 | + `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`, | |
| 353 | + ); | |
| 354 | + if (!raw) return null; | |
| 355 | + try { | |
| 356 | + return JSON.parse(raw) as SourceOAuthPopupMessage; | |
| 357 | + } catch { | |
| 358 | + return null; | |
| 359 | + } | |
| 360 | +}; | |
| 361 | + | |
| 362 | +const clearStoredSourceOAuthPopupResult = (sessionId: string): void => { | |
| 363 | + if (typeof window === "undefined") return; | |
| 364 | + window.localStorage.removeItem(`${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`); | |
| 365 | +}; | |
| 366 | + | |
| 367 | +const startSourceOAuthPopup = async (input: { | |
| 368 | + authorizationUrl: string; | |
| 369 | + sessionId: string; | |
| 370 | +}): Promise<CompleteSourceOAuthResult["auth"]> => { | |
| 371 | + if (typeof window === "undefined") { | |
| 372 | + throw new Error("OAuth popup is only available in a browser context"); | |
| 373 | + } | |
| 374 | + | |
| 375 | + clearStoredSourceOAuthPopupResult(input.sessionId); | |
| 376 | + | |
| 377 | + const popup = window.open( | |
| 378 | + input.authorizationUrl, | |
| 379 | + "executor-v3-source-oauth", | |
| 380 | + "popup=yes,width=520,height=720", | |
| 381 | + ); | |
| 382 | + | |
| 383 | + if (!popup) { | |
| 384 | + throw new Error("Popup blocked. Allow popups and try again."); | |
| 385 | + } | |
| 386 | + | |
| 387 | + popup.focus(); | |
| 388 | + | |
| 389 | + return await new Promise<CompleteSourceOAuthResult["auth"]>((resolve, reject) => { | |
| 390 | + let settled = false; | |
| 391 | + let closedPoll = 0; | |
| 392 | + let resultTimeout = 0; | |
| 393 | + | |
| 394 | + const cleanup = () => { | |
| 395 | + window.removeEventListener("message", onMessage); | |
| 396 | + if (closedPoll) window.clearInterval(closedPoll); | |
| 397 | + if (resultTimeout) window.clearTimeout(resultTimeout); | |
| 398 | + if (!popup.closed) popup.close(); | |
| 399 | + clearStoredSourceOAuthPopupResult(input.sessionId); | |
| 400 | + }; | |
| 401 | + | |
| 402 | + const settleWithError = (message: string) => { | |
| 403 | + if (settled) return; | |
| 404 | + settled = true; | |
| 405 | + cleanup(); | |
| 406 | + reject(new Error(message)); | |
| 407 | + }; | |
| 408 | + | |
| 409 | + const settleFromPayload = (data: SourceOAuthPopupMessage) => { | |
| 410 | + if (!data.ok) { | |
| 411 | + settleWithError(data.error || "OAuth failed"); | |
| 412 | + return; | |
| 413 | + } | |
| 414 | + if (settled) return; | |
| 415 | + settled = true; | |
| 416 | + cleanup(); | |
| 417 | + resolve(data.auth); | |
| 418 | + }; | |
| 419 | + | |
| 420 | + const onMessage = (event: MessageEvent) => { | |
| 421 | + if (event.origin !== window.location.origin) return; | |
| 422 | + const data = event.data as SourceOAuthPopupMessage | undefined; | |
| 423 | + if (!data || data.type !== "executor-v3:oauth-result") return; | |
| 424 | + if (data.ok && data.sessionId !== input.sessionId) return; | |
| 425 | + if (!data.ok && data.sessionId !== null && data.sessionId !== input.sessionId) return; | |
| 426 | + settleFromPayload(data); | |
| 427 | + }; | |
| 428 | + | |
| 429 | + window.addEventListener("message", onMessage); | |
| 430 | + | |
| 431 | + resultTimeout = window.setTimeout(() => { | |
| 432 | + settleWithError("OAuth popup timed out before completion. Please try again."); | |
| 433 | + }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS); | |
| 434 | + | |
| 435 | + closedPoll = window.setInterval(() => { | |
| 436 | + const stored = readStoredSourceOAuthPopupResult(input.sessionId); | |
| 437 | + if (stored) { | |
| 438 | + settleFromPayload(stored); | |
| 439 | + return; | |
| 440 | + } | |
| 441 | + if (popup.closed) { | |
| 442 | + window.setTimeout(() => { | |
| 443 | + const delayedStored = readStoredSourceOAuthPopupResult(input.sessionId); | |
| 444 | + if (delayedStored) { | |
| 445 | + settleFromPayload(delayedStored); | |
| 446 | + return; | |
| 447 | + } | |
| 448 | + settleWithError("OAuth popup was closed before completion."); | |
| 449 | + }, 400); | |
| 450 | + } | |
| 451 | + }, 300); | |
| 452 | + }); | |
| 453 | +}; | |
| 454 | + | |
| 455 | +// --------------------------------------------------------------------------- | |
| 456 | +// Confidence badge helper | |
| 457 | +// --------------------------------------------------------------------------- | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | +// --------------------------------------------------------------------------- | |
| 462 | +// Page | |
| 463 | +// --------------------------------------------------------------------------- | |
| 464 | + | |
| 465 | +export function AddSourcePage() { | |
| 466 | + const navigate = useNavigate(); | |
| 467 | + const discoverSource = useDiscoverSource(); | |
| 468 | + const connectSource = useConnectSource(); | |
| 469 | + const secrets = useSecrets(); | |
| 470 | + const refreshSecrets = useRefreshSecrets(); | |
| 471 | + const startSourceOAuth = useStartSourceOAuth(); | |
| 472 | + | |
| 473 | + // URL input | |
| 474 | + const [url, setUrl] = useState(""); | |
| 475 | + | |
| 476 | + // Probe auth | |
| 477 | + const [showProbeAuth, setShowProbeAuth] = useState(false); | |
| 478 | + const [probeAuth, setProbeAuth] = useState<ProbeAuthState>({ | |
| 479 | + kind: "none", | |
| 480 | + token: "", | |
| 481 | + headerName: "Authorization", | |
| 482 | + prefix: "Bearer ", | |
| 483 | + username: "", | |
| 484 | + password: "", | |
| 485 | + headersText: "", | |
| 486 | + }); | |
| 487 | + | |
| 488 | + // Phase | |
| 489 | + const [phase, setPhase] = useState<FlowPhase>("idle"); | |
| 490 | + | |
| 491 | + // Discovery result | |
| 492 | + const [discovery, setDiscovery] = useState<SourceDiscoveryResult | null>(null); | |
| 493 | + | |
| 494 | + // Editable connect form (populated after discovery) | |
| 495 | + const [connectForm, setConnectForm] = useState<ConnectFormState>(defaultConnectForm()); | |
| 496 | + | |
| 497 | + // Connect result | |
| 498 | + const [connectResult, setConnectResult] = useState<ConnectSourceResult | null>(null); | |
| 499 | + | |
| 500 | + // OAuth required state | |
| 501 | + const [oauthInfo, setOauthInfo] = useState<OAuthRequiredInfo | null>(null); | |
| 502 | + const [oauthBusy, setOauthBusy] = useState(false); | |
| 503 | + | |
| 504 | + // Status banner | |
| 505 | + const [statusBanner, setStatusBanner] = useState<{ | |
| 506 | + tone: "info" | "success" | "error"; | |
| 507 | + text: string; | |
| 508 | + } | null>(null); | |
| 509 | + | |
| 510 | + const setFormField = <K extends keyof ConnectFormState>(key: K, value: ConnectFormState[K]) => { | |
| 511 | + setConnectForm((current) => ({ ...current, [key]: value })); | |
| 512 | + }; | |
| 513 | + | |
| 514 | + const setProbeField = <K extends keyof ProbeAuthState>(key: K, value: ProbeAuthState[K]) => { | |
| 515 | + setProbeAuth((current) => ({ ...current, [key]: value })); | |
| 516 | + }; | |
| 517 | + | |
| 518 | + // ------------------------------------------------------------------------- | |
| 519 | + // Handlers | |
| 520 | + // ------------------------------------------------------------------------- | |
| 521 | + | |
| 522 | + const handleDiscover = async () => { | |
| 523 | + setStatusBanner(null); | |
| 524 | + const trimmedUrl = url.trim(); | |
| 525 | + if (!trimmedUrl) { | |
| 526 | + setStatusBanner({ tone: "error", text: "Please enter a URL to discover." }); | |
| 527 | + return; | |
| 528 | + } | |
| 529 | + | |
| 530 | + setPhase("discovering"); | |
| 531 | + | |
| 532 | + try { | |
| 533 | + const payload: DiscoverSourcePayload = { | |
| 534 | + url: trimmedUrl, | |
| 535 | + ...(showProbeAuth && probeAuth.kind !== "none" | |
| 536 | + ? { probeAuth: buildProbeAuth(probeAuth) } | |
| 537 | + : {}), | |
| 538 | + }; | |
| 539 | + const result = await discoverSource.mutateAsync(payload); | |
| 540 | + setDiscovery(result); | |
| 541 | + setConnectForm(defaultConnectForm(result)); | |
| 542 | + setPhase("editing"); | |
| 543 | + | |
| 544 | + if (result.detectedKind === "unknown") { | |
| 545 | + setStatusBanner({ | |
| 546 | + tone: "info", | |
| 547 | + text: "Could not auto-detect the source type. Please configure manually.", | |
| 548 | + }); | |
| 549 | + } else if (result.warnings.length > 0) { | |
| 550 | + setStatusBanner({ | |
| 551 | + tone: "info", | |
| 552 | + text: result.warnings.join(" "), | |
| 553 | + }); | |
| 554 | + } | |
| 555 | + } catch (error) { | |
| 556 | + setPhase("idle"); | |
| 557 | + setStatusBanner({ | |
| 558 | + tone: "error", | |
| 559 | + text: error instanceof Error ? error.message : "Discovery failed.", | |
| 560 | + }); | |
| 561 | + } | |
| 562 | + }; | |
| 563 | + | |
| 564 | + const handleSkipDiscovery = () => { | |
| 565 | + setDiscovery(null); | |
| 566 | + setConnectForm(defaultConnectForm()); | |
| 567 | + setPhase("editing"); | |
| 568 | + setStatusBanner(null); | |
| 569 | + }; | |
| 570 | + | |
| 571 | + const applyTemplate = async (template: SourceTemplate) => { | |
| 572 | + setUrl(template.endpoint); | |
| 573 | + setStatusBanner(null); | |
| 574 | + setPhase("discovering"); | |
| 575 | + | |
| 576 | + try { | |
| 577 | + const result = await discoverSource.mutateAsync({ url: template.endpoint }); | |
| 578 | + setDiscovery(result); | |
| 579 | + const form = defaultConnectForm(result); | |
| 580 | + // Prefer the template's name over whatever discover returned | |
| 581 | + form.name = template.name; | |
| 582 | + setConnectForm(form); | |
| 583 | + setPhase("editing"); | |
| 584 | + | |
| 585 | + if (result.warnings.length > 0) { | |
| 586 | + setStatusBanner({ | |
| 587 | + tone: "info", | |
| 588 | + text: result.warnings.join(" "), | |
| 589 | + }); | |
| 590 | + } | |
| 591 | + } catch (error) { | |
| 592 | + // Discovery failed — fall back to just the template basics | |
| 593 | + setDiscovery(null); | |
| 594 | + setConnectForm(connectFormFromTemplate(template)); | |
| 595 | + setPhase("editing"); | |
| 596 | + setStatusBanner({ | |
| 597 | + tone: "error", | |
| 598 | + text: `Discovery failed for ${template.name}: ${error instanceof Error ? error.message : "unknown error"}. Configure manually.`, | |
| 599 | + }); | |
| 600 | + } | |
| 601 | + }; | |
| 602 | + | |
| 603 | + const handleConnect = async () => { | |
| 604 | + setStatusBanner(null); | |
| 605 | + | |
| 606 | + try { | |
| 607 | + const payload = buildConnectPayload(connectForm); | |
| 608 | + setPhase("connecting"); | |
| 609 | + | |
| 610 | + const result = await connectSource.mutateAsync(payload); | |
| 611 | + setConnectResult(result); | |
| 612 | + | |
| 613 | + if (result.kind === "connected") { | |
| 614 | + setPhase("connected"); | |
| 615 | + setStatusBanner({ | |
| 616 | + tone: "success", | |
| 617 | + text: `"${result.source.name}" connected successfully.`, | |
| 618 | + }); | |
| 619 | + // Navigate to source detail after short delay | |
| 620 | + setTimeout(() => { | |
| 621 | + void navigate({ | |
| 622 | + to: "/sources/$sourceId", | |
| 623 | + params: { sourceId: result.source.id }, | |
| 624 | + search: { tab: "model" }, | |
| 625 | + }); | |
| 626 | + }, 1200); | |
| 627 | + } else if (result.kind === "credential_required") { | |
| 628 | + setPhase("credential_required"); | |
| 629 | + setStatusBanner({ | |
| 630 | + tone: "info", | |
| 631 | + text: "This source requires credentials. Configure auth below, then connect again.", | |
| 632 | + }); | |
| 633 | + // Pre-select bearer auth if not already set | |
| 634 | + if (connectForm.authKind === "none") { | |
| 635 | + setFormField("authKind", "bearer"); | |
| 636 | + } | |
| 637 | + } else if (result.kind === "oauth_required") { | |
| 638 | + setPhase("oauth_required"); | |
| 639 | + setOauthInfo({ | |
| 640 | + source: result.source, | |
| 641 | + sessionId: result.sessionId, | |
| 642 | + authorizationUrl: result.authorizationUrl, | |
| 643 | + }); | |
| 644 | + setStatusBanner({ | |
| 645 | + tone: "info", | |
| 646 | + text: "This source requires OAuth authentication. Click the button below to sign in.", | |
| 647 | + }); | |
| 648 | + } | |
| 649 | + } catch (error) { | |
| 650 | + setPhase("editing"); | |
| 651 | + setStatusBanner({ | |
| 652 | + tone: "error", | |
| 653 | + text: error instanceof Error ? error.message : "Connection failed.", | |
| 654 | + }); | |
| 655 | + } | |
| 656 | + }; | |
| 657 | + | |
| 658 | + const handleOAuthPopup = async () => { | |
| 659 | + if (!oauthInfo) return; | |
| 660 | + setStatusBanner(null); | |
| 661 | + setOauthBusy(true); | |
| 662 | + | |
| 663 | + try { | |
| 664 | + await startSourceOAuthPopup({ | |
| 665 | + authorizationUrl: oauthInfo.authorizationUrl, | |
| 666 | + sessionId: oauthInfo.sessionId, | |
| 667 | + }); | |
| 668 | + | |
| 669 | + refreshSecrets(); | |
| 670 | + setPhase("connected"); | |
| 671 | + setStatusBanner({ | |
| 672 | + tone: "success", | |
| 673 | + text: `"${oauthInfo.source.name}" connected via OAuth.`, | |
| 674 | + }); | |
| 675 | + | |
| 676 | + setTimeout(() => { | |
| 677 | + void navigate({ | |
| 678 | + to: "/sources/$sourceId", | |
| 679 | + params: { sourceId: oauthInfo.source.id }, | |
| 680 | + search: { tab: "model" }, | |
| 681 | + }); | |
| 682 | + }, 1200); | |
| 683 | + } catch (error) { | |
| 684 | + setStatusBanner({ | |
| 685 | + tone: "error", | |
| 686 | + text: error instanceof Error ? error.message : "OAuth flow failed.", | |
| 687 | + }); | |
| 688 | + } finally { | |
| 689 | + setOauthBusy(false); | |
| 690 | + } | |
| 691 | + }; | |
| 692 | + | |
| 693 | + const handleCredentialConnect = async () => { | |
| 694 | + // Re-run connect with the auth now configured | |
| 695 | + await handleConnect(); | |
| 696 | + }; | |
| 697 | + | |
| 698 | + const handleBackToEditing = () => { | |
| 699 | + setPhase("editing"); | |
| 700 | + setStatusBanner(null); | |
| 701 | + setConnectResult(null); | |
| 702 | + setOauthInfo(null); | |
| 703 | + }; | |
| 704 | + | |
| 705 | + const isDiscovering = phase === "discovering"; | |
| 706 | + const isConnecting = phase === "connecting"; | |
| 707 | + const isBusy = isDiscovering || isConnecting || oauthBusy; | |
| 708 | + | |
| 709 | + // ------------------------------------------------------------------------- | |
| 710 | + // Render | |
| 711 | + // ------------------------------------------------------------------------- | |
| 712 | + | |
| 713 | + return ( | |
| 714 | + <div className="flex-1 overflow-y-auto"> | |
| 715 | + <div className="mx-auto max-w-3xl px-6 py-8 lg:px-10 lg:py-12"> | |
| 716 | + {/* Back */} | |
| 717 | + <Link | |
| 718 | + to="/" | |
| 719 | + className="inline-flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground mb-6" | |
| 720 | + > | |
| 721 | + <IconArrowLeft className="size-3.5" /> | |
| 722 | + Back | |
| 723 | + </Link> | |
| 724 | + | |
| 725 | + <div className="flex items-center justify-between gap-4 mb-6"> | |
| 726 | + <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl"> | |
| 727 | + Add source | |
| 728 | + </h1> | |
| 729 | + {phase === "editing" && ( | |
| 730 | + <Badge variant="outline">{connectForm.kind}</Badge> | |
| 731 | + )} | |
| 732 | + {phase === "connected" && ( | |
| 733 | + <Badge variant="default">connected</Badge> | |
| 734 | + )} | |
| 735 | + </div> | |
| 736 | + | |
| 737 | + {statusBanner && <StatusBanner state={statusBanner} className="mb-6" />} | |
| 738 | + | |
| 739 | + {/* Step 1: Discovery */} | |
| 740 | + {(phase === "idle" || phase === "discovering") && ( | |
| 741 | + <div className="space-y-6"> | |
| 742 | + <Section title="Discover"> | |
| 743 | + <div className="space-y-4"> | |
| 744 | + <p className="text-[13px] text-muted-foreground"> | |
| 745 | + Enter a URL and we'll auto-detect the source type, endpoint, auth requirements, and more. | |
| 746 | + </p> | |
| 747 | + | |
| 748 | + <Field label="URL"> | |
| 749 | + <div className="flex gap-2"> | |
| 750 | + <input | |
| 751 | + value={url} | |
| 752 | + onChange={(e) => setUrl(e.target.value)} | |
| 753 | + placeholder="https://api.example.com or https://mcp.example.com/mcp" | |
| 754 | + onKeyDown={(e) => { | |
| 755 | + if (e.key === "Enter" && !isDiscovering) { | |
| 756 | + void handleDiscover(); | |
| 757 | + } | |
| 758 | + }} | |
| 759 | + className="h-9 flex-1 rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 760 | + /> | |
| 761 | + <Button onClick={handleDiscover} disabled={isBusy}> | |
| 762 | + {isDiscovering ? <IconSpinner className="size-3.5" /> : <IconDiscover className="size-3.5" />} | |
| 763 | + {isDiscovering ? "Discovering\u2026" : "Discover"} | |
| 764 | + </Button> | |
| 765 | + </div> | |
| 766 | + </Field> | |
| 767 | + | |
| 768 | + {/* Probe auth toggle */} | |
| 769 | + <div> | |
| 770 | + <button | |
| 771 | + type="button" | |
| 772 | + onClick={() => setShowProbeAuth(!showProbeAuth)} | |
| 773 | + className="text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground" | |
| 774 | + > | |
| 775 | + {showProbeAuth ? "Hide probe auth" : "Need auth to discover?"} | |
| 776 | + </button> | |
| 777 | + </div> | |
| 778 | + | |
| 779 | + {showProbeAuth && ( | |
| 780 | + <div className="rounded-lg border border-border bg-card/70 p-4 space-y-3"> | |
| 781 | + <Field label="Auth type"> | |
| 782 | + <SelectInput | |
| 783 | + value={probeAuth.kind} | |
| 784 | + onChange={(v) => setProbeField("kind", v as ProbeAuthKind)} | |
| 785 | + options={probeAuthOptions.map((v) => ({ value: v, label: v }))} | |
| 786 | + /> | |
| 787 | + </Field> | |
| 788 | + | |
| 789 | + {probeAuth.kind === "bearer" && ( | |
| 790 | + <div className="grid gap-3 sm:grid-cols-2"> | |
| 791 | + <Field label="Header name"> | |
| 792 | + <TextInput | |
| 793 | + value={probeAuth.headerName} | |
| 794 | + onChange={(v) => setProbeField("headerName", v)} | |
| 795 | + placeholder="Authorization" | |
| 796 | + /> | |
| 797 | + </Field> | |
| 798 | + <Field label="Prefix"> | |
| 799 | + <TextInput | |
| 800 | + value={probeAuth.prefix} | |
| 801 | + onChange={(v) => setProbeField("prefix", v)} | |
| 802 | + placeholder="Bearer " | |
| 803 | + /> | |
| 804 | + </Field> | |
| 805 | + <Field label="Token" className="sm:col-span-2"> | |
| 806 | + <TextInput | |
| 807 | + value={probeAuth.token} | |
| 808 | + onChange={(v) => setProbeField("token", v)} | |
| 809 | + placeholder="sk-..." | |
| 810 | + mono | |
| 811 | + /> | |
| 812 | + </Field> | |
| 813 | + </div> | |
| 814 | + )} | |
| 815 | + | |
| 816 | + {probeAuth.kind === "basic" && ( | |
| 817 | + <div className="grid gap-3 sm:grid-cols-2"> | |
| 818 | + <Field label="Username"> | |
| 819 | + <TextInput | |
| 820 | + value={probeAuth.username} | |
| 821 | + onChange={(v) => setProbeField("username", v)} | |
| 822 | + placeholder="user" | |
| 823 | + /> | |
| 824 | + </Field> | |
| 825 | + <Field label="Password"> | |
| 826 | + <TextInput | |
| 827 | + value={probeAuth.password} | |
| 828 | + onChange={(v) => setProbeField("password", v)} | |
| 829 | + placeholder="pass" | |
| 830 | + /> | |
| 831 | + </Field> | |
| 832 | + </div> | |
| 833 | + )} | |
| 834 | + | |
| 835 | + {probeAuth.kind === "headers" && ( | |
| 836 | + <Field label="Headers (JSON)"> | |
| 837 | + <CodeEditor | |
| 838 | + value={probeAuth.headersText} | |
| 839 | + onChange={(v) => setProbeField("headersText", v)} | |
| 840 | + placeholder={'{\n "x-api-key": "..."\n}'} | |
| 841 | + /> | |
| 842 | + </Field> | |
| 843 | + )} | |
| 844 | + </div> | |
| 845 | + )} | |
| 846 | + | |
| 847 | + {/* Skip discovery link */} | |
| 848 | + <div className="flex items-center justify-end border-t border-border pt-4"> | |
| 849 | + <button | |
| 850 | + type="button" | |
| 851 | + onClick={handleSkipDiscovery} | |
| 852 | + disabled={isBusy} | |
| 853 | + className="text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground" | |
| 854 | + > | |
| 855 | + Skip discovery and configure manually | |
| 856 | + </button> | |
| 857 | + </div> | |
| 858 | + | |
| 859 | + <div className="space-y-3 border-t border-border pt-4"> | |
| 860 | + <div> | |
| 861 | + <p className="text-[12px] font-medium text-foreground">Start from a known source</p> | |
| 862 | + <p className="text-[11px] text-muted-foreground"> | |
| 863 | + Load a real MCP, OpenAPI, or GraphQL template without running discovery first. | |
| 864 | + </p> | |
| 865 | + </div> | |
| 866 | + <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 867 | + {sourceTemplates.map((template) => ( | |
| 868 | + <button | |
| 869 | + key={template.id} | |
| 870 | + type="button" | |
| 871 | + onClick={() => applyTemplate(template)} | |
| 872 | + disabled={isBusy} | |
| 873 | + className="rounded-xl border border-border bg-card/70 px-4 py-3 text-left transition-colors hover:bg-accent/50 disabled:opacity-60" | |
| 874 | + > | |
| 875 | + <div className="mb-1 flex items-center justify-between gap-2"> | |
| 876 | + <div className="flex min-w-0 items-center gap-2"> | |
| 877 | + <div className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"> | |
| 878 | + <SourceFavicon endpoint={template.endpoint} kind={template.kind} className="size-4" /> | |
| 879 | + </div> | |
| 880 | + <span className="truncate text-[13px] font-medium text-foreground">{template.name}</span> | |
| 881 | + </div> | |
| 882 | + <Badge variant="outline" className="text-[9px]">{template.kind}</Badge> | |
| 883 | + </div> | |
| 884 | + <span className="line-clamp-2 text-[11px] text-muted-foreground"> | |
| 885 | + {template.summary} | |
| 886 | + </span> | |
| 887 | + </button> | |
| 888 | + ))} | |
| 889 | + </div> | |
| 890 | + </div> | |
| 891 | + </div> | |
| 892 | + </Section> | |
| 893 | + </div> | |
| 894 | + )} | |
| 895 | + | |
| 896 | + | |
| 897 | + | |
| 898 | + {/* Step 2-4: Editing / Connecting */} | |
| 899 | + {(phase === "editing" || phase === "connecting" || phase === "credential_required") && ( | |
| 900 | + <div className="space-y-6"> | |
| 901 | + <Section title="Configuration"> | |
| 902 | + <div className="grid gap-4 sm:grid-cols-2"> | |
| 903 | + <Field label="Name"> | |
| 904 | + <TextInput | |
| 905 | + value={connectForm.name} | |
| 906 | + onChange={(v) => setFormField("name", v)} | |
| 907 | + placeholder="My API" | |
| 908 | + /> | |
| 909 | + </Field> | |
| 910 | + <Field label="Kind"> | |
| 911 | + <SelectInput | |
| 912 | + value={connectForm.kind} | |
| 913 | + onChange={(v) => setFormField("kind", v as ConnectFormState["kind"])} | |
| 914 | + options={kindOptions.map((v) => ({ value: v, label: v }))} | |
| 915 | + /> | |
| 916 | + </Field> | |
| 917 | + <Field label="Endpoint" className="sm:col-span-2"> | |
| 918 | + <TextInput | |
| 919 | + value={connectForm.endpoint} | |
| 920 | + onChange={(v) => setFormField("endpoint", v)} | |
| 921 | + placeholder="https://api.example.com" | |
| 922 | + mono | |
| 923 | + /> | |
| 924 | + </Field> | |
| 925 | + <Field label="Namespace"> | |
| 926 | + <TextInput | |
| 927 | + value={connectForm.namespace} | |
| 928 | + onChange={(v) => setFormField("namespace", v)} | |
| 929 | + placeholder="example" | |
| 930 | + /> | |
| 931 | + </Field> | |
| 932 | + {connectForm.kind === "openapi" && ( | |
| 933 | + <Field label="Spec URL" className="sm:col-span-2"> | |
| 934 | + <TextInput | |
| 935 | + value={connectForm.specUrl} | |
| 936 | + onChange={(v) => setFormField("specUrl", v)} | |
| 937 | + placeholder="https://example.com/openapi.yaml" | |
| 938 | + mono | |
| 939 | + /> | |
| 940 | + </Field> | |
| 941 | + )} | |
| 942 | + </div> | |
| 943 | + </Section> | |
| 944 | + | |
| 945 | + {/* MCP Transport */} | |
| 946 | + {connectForm.kind === "mcp" && ( | |
| 947 | + <Section title="Transport"> | |
| 948 | + <div className="grid gap-4 sm:grid-cols-2"> | |
| 949 | + <Field label="Transport mode"> | |
| 950 | + <SelectInput | |
| 951 | + value={connectForm.transport || "auto"} | |
| 952 | + onChange={(v) => setFormField("transport", v as ConnectFormState["transport"])} | |
| 953 | + options={transportOptions.map((v) => ({ value: v, label: v }))} | |
| 954 | + /> | |
| 955 | + </Field> | |
| 956 | + <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2"> | |
| 957 | + <Field label="Query params (JSON)"> | |
| 958 | + <CodeEditor | |
| 959 | + value={connectForm.queryParamsText} | |
| 960 | + onChange={(v) => setFormField("queryParamsText", v)} | |
| 961 | + placeholder={'{\n "workspace": "demo"\n}'} | |
| 962 | + /> | |
| 963 | + </Field> | |
| 964 | + <Field label="Headers (JSON)"> | |
| 965 | + <CodeEditor | |
| 966 | + value={connectForm.headersText} | |
| 967 | + onChange={(v) => setFormField("headersText", v)} | |
| 968 | + placeholder={'{\n "x-api-key": "..."\n}'} | |
| 969 | + /> | |
| 970 | + </Field> | |
| 971 | + </div> | |
| 972 | + </div> | |
| 973 | + </Section> | |
| 974 | + )} | |
| 975 | + | |
| 976 | + {/* Auth section (for non-MCP kinds, or when credential_required) */} | |
| 977 | + {connectForm.kind !== "mcp" && ( | |
| 978 | + <Section title="Authentication"> | |
| 979 | + <div className="grid gap-4 sm:grid-cols-2"> | |
| 980 | + <Field label="Auth mode"> | |
| 981 | + <SelectInput | |
| 982 | + value={connectForm.authKind} | |
| 983 | + onChange={(v) => setFormField("authKind", v as ConnectFormState["authKind"])} | |
| 984 | + options={authOptions.map((v) => ({ value: v, label: v }))} | |
| 985 | + /> | |
| 986 | + </Field> | |
| 987 | + {connectForm.authKind !== "none" && ( | |
| 988 | + <> | |
| 989 | + <Field label="Header name"> | |
| 990 | + <TextInput | |
| 991 | + value={connectForm.authHeaderName} | |
| 992 | + onChange={(v) => setFormField("authHeaderName", v)} | |
| 993 | + placeholder="Authorization" | |
| 994 | + /> | |
| 995 | + </Field> | |
| 996 | + <Field label="Prefix"> | |
| 997 | + <TextInput | |
| 998 | + value={connectForm.authPrefix} | |
| 999 | + onChange={(v) => setFormField("authPrefix", v)} | |
| 1000 | + placeholder="Bearer " | |
| 1001 | + /> | |
| 1002 | + </Field> | |
| 1003 | + </> | |
| 1004 | + )} | |
| 1005 | + {connectForm.authKind === "bearer" && ( | |
| 1006 | + <Field label="Token" className="sm:col-span-2"> | |
| 1007 | + <SecretOrTokenInput | |
| 1008 | + secrets={secrets} | |
| 1009 | + providerId={connectForm.bearerProviderId} | |
| 1010 | + handle={connectForm.bearerHandle} | |
| 1011 | + inlineToken={connectForm.bearerToken} | |
| 1012 | + onSelectSecret={(providerId, handle) => { | |
| 1013 | + setFormField("bearerProviderId", providerId); | |
| 1014 | + setFormField("bearerHandle", handle); | |
| 1015 | + setFormField("bearerToken", ""); | |
| 1016 | + }} | |
| 1017 | + onChangeToken={(token) => { | |
| 1018 | + setFormField("bearerToken", token); | |
| 1019 | + setFormField("bearerProviderId", ""); | |
| 1020 | + setFormField("bearerHandle", ""); | |
| 1021 | + }} | |
| 1022 | + /> | |
| 1023 | + </Field> | |
| 1024 | + )} | |
| 1025 | + </div> | |
| 1026 | + </Section> | |
| 1027 | + )} | |
| 1028 | + | |
| 1029 | + {/* Actions */} | |
| 1030 | + <div className="flex items-center justify-end gap-3 border-t border-border pt-5"> | |
| 1031 | + {phase === "credential_required" && ( | |
| 1032 | + <Button variant="ghost" type="button" onClick={handleBackToEditing}> | |
| 1033 | + Back to edit | |
| 1034 | + </Button> | |
| 1035 | + )} | |
| 1036 | + <Link to="/" className="inline-flex"> | |
| 1037 | + <Button variant="ghost" type="button">Cancel</Button> | |
| 1038 | + </Link> | |
| 1039 | + <Button | |
| 1040 | + onClick={phase === "credential_required" ? handleCredentialConnect : handleConnect} | |
| 1041 | + disabled={isBusy} | |
| 1042 | + > | |
| 1043 | + {isConnecting ? <IconSpinner className="size-3.5" /> : <IconPlus className="size-3.5" />} | |
| 1044 | + {isConnecting ? "Connecting\u2026" : "Connect"} | |
| 1045 | + </Button> | |
| 1046 | + </div> | |
| 1047 | + </div> | |
| 1048 | + )} | |
| 1049 | + | |
| 1050 | + {/* Step 5a: OAuth required */} | |
| 1051 | + {phase === "oauth_required" && oauthInfo && ( | |
| 1052 | + <div className="space-y-6"> | |
| 1053 | + <Section title="OAuth Authentication Required"> | |
| 1054 | + <div className="space-y-4"> | |
| 1055 | + <p className="text-[13px] text-muted-foreground"> | |
| 1056 | + The source <strong className="text-foreground">{oauthInfo.source.name}</strong> requires OAuth. | |
| 1057 | + Click the button below to open a popup and complete authentication. | |
| 1058 | + </p> | |
| 1059 | + <div className="flex items-center gap-3"> | |
| 1060 | + <Button onClick={handleOAuthPopup} disabled={oauthBusy}> | |
| 1061 | + {oauthBusy ? <IconSpinner className="size-3.5" /> : null} | |
| 1062 | + {oauthBusy ? "Authenticating\u2026" : "Sign in with OAuth"} | |
| 1063 | + </Button> | |
| 1064 | + <Button variant="ghost" onClick={handleBackToEditing}> | |
| 1065 | + Back to edit | |
| 1066 | + </Button> | |
| 1067 | + </div> | |
| 1068 | + </div> | |
| 1069 | + </Section> | |
| 1070 | + </div> | |
| 1071 | + )} | |
| 1072 | + | |
| 1073 | + {/* Step 5b: Connected */} | |
| 1074 | + {phase === "connected" && connectResult && ( | |
| 1075 | + <Section title="Connected"> | |
| 1076 | + <div className="flex items-center gap-3"> | |
| 1077 | + <div className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary"> | |
| 1078 | + <IconCheck className="size-5" /> | |
| 1079 | + </div> | |
| 1080 | + <div> | |
| 1081 | + <p className="text-[14px] font-medium text-foreground"> | |
| 1082 | + {connectResult.source.name} | |
| 1083 | + </p> | |
| 1084 | + <p className="text-[12px] text-muted-foreground"> | |
| 1085 | + Source connected. Redirecting... | |
| 1086 | + </p> | |
| 1087 | + </div> | |
| 1088 | + </div> | |
| 1089 | + </Section> | |
| 1090 | + )} | |
| 1091 | + </div> | |
| 1092 | + </div> | |
| 1093 | + ); | |
| 1094 | +} | |
| 1095 | + | |
| 1096 | +// --------------------------------------------------------------------------- | |
| 1097 | +// Form building blocks | |
| 1098 | +// --------------------------------------------------------------------------- | |
| 1099 | + | |
| 1100 | +function Section(props: { title: string; children: ReactNode; className?: string }) { | |
| 1101 | + return ( | |
| 1102 | + <section className={cn("rounded-xl border border-border bg-card/80", props.className)}> | |
| 1103 | + <div className="border-b border-border px-5 py-3"> | |
| 1104 | + <h2 className="text-sm font-semibold text-foreground">{props.title}</h2> | |
| 1105 | + </div> | |
| 1106 | + <div className="p-5">{props.children}</div> | |
| 1107 | + </section> | |
| 1108 | + ); | |
| 1109 | +} | |
| 1110 | + | |
| 1111 | +function Field(props: { | |
| 1112 | + label: string; | |
| 1113 | + className?: string; | |
| 1114 | + children: ReactNode; | |
| 1115 | +}) { | |
| 1116 | + return ( | |
| 1117 | + <label className={cn("block space-y-1.5", props.className)}> | |
| 1118 | + <span className="text-[12px] font-medium text-foreground">{props.label}</span> | |
| 1119 | + {props.children} | |
| 1120 | + </label> | |
| 1121 | + ); | |
| 1122 | +} | |
| 1123 | + | |
| 1124 | +function TextInput(props: { | |
| 1125 | + value: string; | |
| 1126 | + onChange: (value: string) => void; | |
| 1127 | + placeholder?: string; | |
| 1128 | + mono?: boolean; | |
| 1129 | +}) { | |
| 1130 | + return ( | |
| 1131 | + <input | |
| 1132 | + value={props.value} | |
| 1133 | + onChange={(event) => props.onChange(event.target.value)} | |
| 1134 | + placeholder={props.placeholder} | |
| 1135 | + className={cn( | |
| 1136 | + "h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25", | |
| 1137 | + props.mono && "font-mono text-[12px]", | |
| 1138 | + )} | |
| 1139 | + /> | |
| 1140 | + ); | |
| 1141 | +} | |
| 1142 | + | |
| 1143 | +function SelectInput(props: { | |
| 1144 | + value: string; | |
| 1145 | + onChange: (value: string) => void; | |
| 1146 | + options: ReadonlyArray<{ value: string; label: string }>; | |
| 1147 | +}) { | |
| 1148 | + return ( | |
| 1149 | + <select | |
| 1150 | + value={props.value} | |
| 1151 | + onChange={(event) => props.onChange(event.target.value)} | |
| 1152 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1153 | + > | |
| 1154 | + {props.options.map((option) => ( | |
| 1155 | + <option key={option.value} value={option.value}> | |
| 1156 | + {option.label} | |
| 1157 | + </option> | |
| 1158 | + ))} | |
| 1159 | + </select> | |
| 1160 | + ); | |
| 1161 | +} | |
| 1162 | + | |
| 1163 | +function CodeEditor(props: { | |
| 1164 | + value: string; | |
| 1165 | + onChange: (value: string) => void; | |
| 1166 | + placeholder?: string; | |
| 1167 | +}) { | |
| 1168 | + return ( | |
| 1169 | + <textarea | |
| 1170 | + value={props.value} | |
| 1171 | + onChange={(event) => props.onChange(event.target.value)} | |
| 1172 | + placeholder={props.placeholder} | |
| 1173 | + spellCheck={false} | |
| 1174 | + className="min-h-28 w-full rounded-lg border border-input bg-background px-3 py-2.5 font-mono text-[12px] leading-relaxed text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1175 | + /> | |
| 1176 | + ); | |
| 1177 | +} | |
| 1178 | + | |
| 1179 | +function StatusBanner(props: { state: { tone: "info" | "success" | "error"; text: string }; className?: string }) { | |
| 1180 | + return ( | |
| 1181 | + <div | |
| 1182 | + className={cn( | |
| 1183 | + "rounded-lg border px-4 py-3 text-[13px]", | |
| 1184 | + props.state.tone === "success" && "border-primary/30 bg-primary/8 text-foreground", | |
| 1185 | + props.state.tone === "info" && "border-border bg-card text-muted-foreground", | |
| 1186 | + props.state.tone === "error" && "border-destructive/30 bg-destructive/8 text-destructive", | |
| 1187 | + props.className, | |
| 1188 | + )} | |
| 1189 | + > | |
| 1190 | + {props.state.text} | |
| 1191 | + </div> | |
| 1192 | + ); | |
| 1193 | +} | |
| 1194 | + | |
| 1195 | + | |
| 1196 | + | |
| 1197 | +// --------------------------------------------------------------------------- | |
| 1198 | +// Secret or inline token picker | |
| 1199 | +// --------------------------------------------------------------------------- | |
| 1200 | + | |
| 1201 | +const CREATE_NEW_VALUE = "__create_new__"; | |
| 1202 | +const INLINE_TOKEN_VALUE = "__inline_token__"; | |
| 1203 | + | |
| 1204 | +function SecretOrTokenInput(props: { | |
| 1205 | + secrets: Loadable<ReadonlyArray<SecretListItem>>; | |
| 1206 | + providerId: string; | |
| 1207 | + handle: string; | |
| 1208 | + inlineToken: string; | |
| 1209 | + onSelectSecret: (providerId: string, handle: string) => void; | |
| 1210 | + onChangeToken: (token: string) => void; | |
| 1211 | +}) { | |
| 1212 | + const { secrets, providerId, handle, inlineToken, onSelectSecret, onChangeToken } = props; | |
| 1213 | + const createSecret = useCreateSecret(); | |
| 1214 | + const [showCreate, setShowCreate] = useState(false); | |
| 1215 | + const [useInline, setUseInline] = useState(false); | |
| 1216 | + const [newName, setNewName] = useState(""); | |
| 1217 | + const [newValue, setNewValue] = useState(""); | |
| 1218 | + const [createError, setCreateError] = useState<string | null>(null); | |
| 1219 | + | |
| 1220 | + const selectedValue = showCreate ? CREATE_NEW_VALUE : useInline ? INLINE_TOKEN_VALUE : (handle || ""); | |
| 1221 | + | |
| 1222 | + const handleSelectChange = (value: string) => { | |
| 1223 | + if (value === CREATE_NEW_VALUE) { | |
| 1224 | + setShowCreate(true); | |
| 1225 | + setUseInline(false); | |
| 1226 | + setNewName(""); | |
| 1227 | + setNewValue(""); | |
| 1228 | + setCreateError(null); | |
| 1229 | + return; | |
| 1230 | + } | |
| 1231 | + if (value === INLINE_TOKEN_VALUE) { | |
| 1232 | + setUseInline(true); | |
| 1233 | + setShowCreate(false); | |
| 1234 | + onSelectSecret("", ""); | |
| 1235 | + return; | |
| 1236 | + } | |
| 1237 | + if (value === "") { | |
| 1238 | + setUseInline(false); | |
| 1239 | + setShowCreate(false); | |
| 1240 | + onSelectSecret("", ""); | |
| 1241 | + return; | |
| 1242 | + } | |
| 1243 | + setUseInline(false); | |
| 1244 | + setShowCreate(false); | |
| 1245 | + onSelectSecret("postgres", value); | |
| 1246 | + }; | |
| 1247 | + | |
| 1248 | + const handleCreate = async () => { | |
| 1249 | + setCreateError(null); | |
| 1250 | + const trimmedName = newName.trim(); | |
| 1251 | + if (!trimmedName) { | |
| 1252 | + setCreateError("Name is required."); | |
| 1253 | + return; | |
| 1254 | + } | |
| 1255 | + if (!newValue) { | |
| 1256 | + setCreateError("Value is required."); | |
| 1257 | + return; | |
| 1258 | + } | |
| 1259 | + | |
| 1260 | + try { | |
| 1261 | + const result = await createSecret.mutateAsync({ name: trimmedName, value: newValue }); | |
| 1262 | + onSelectSecret(result.providerId, result.id); | |
| 1263 | + setShowCreate(false); | |
| 1264 | + } catch (err) { | |
| 1265 | + setCreateError(err instanceof Error ? err.message : "Failed creating secret."); | |
| 1266 | + } | |
| 1267 | + }; | |
| 1268 | + | |
| 1269 | + if (secrets.status !== "ready") { | |
| 1270 | + return ( | |
| 1271 | + <select | |
| 1272 | + disabled | |
| 1273 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-muted-foreground outline-none opacity-60" | |
| 1274 | + > | |
| 1275 | + <option>Loading...</option> | |
| 1276 | + </select> | |
| 1277 | + ); | |
| 1278 | + } | |
| 1279 | + | |
| 1280 | + const items = secrets.data; | |
| 1281 | + | |
| 1282 | + return ( | |
| 1283 | + <div className="space-y-2"> | |
| 1284 | + <select | |
| 1285 | + value={selectedValue} | |
| 1286 | + onChange={(e) => handleSelectChange(e.target.value)} | |
| 1287 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1288 | + > | |
| 1289 | + <option value="">Select a secret...</option> | |
| 1290 | + {items.map((secret) => ( | |
| 1291 | + <option key={secret.id} value={secret.id}> | |
| 1292 | + {secret.name || secret.id} | |
| 1293 | + </option> | |
| 1294 | + ))} | |
| 1295 | + <option value={INLINE_TOKEN_VALUE}>Paste token directly</option> | |
| 1296 | + <option value={CREATE_NEW_VALUE}>+ Create new secret</option> | |
| 1297 | + </select> | |
| 1298 | + | |
| 1299 | + {useInline && ( | |
| 1300 | + <input | |
| 1301 | + type="password" | |
| 1302 | + value={inlineToken} | |
| 1303 | + onChange={(e) => onChangeToken(e.target.value)} | |
| 1304 | + placeholder="sk-... or ghp_..." | |
| 1305 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1306 | + autoFocus | |
| 1307 | + /> | |
| 1308 | + )} | |
| 1309 | + | |
| 1310 | + {showCreate && ( | |
| 1311 | + <div className="rounded-lg border border-primary/20 bg-card/80 p-3 space-y-3"> | |
| 1312 | + {createError && ( | |
| 1313 | + <div className="rounded-md border border-destructive/30 bg-destructive/8 px-3 py-2 text-[12px] text-destructive"> | |
| 1314 | + {createError} | |
| 1315 | + </div> | |
| 1316 | + )} | |
| 1317 | + <div className="grid gap-3 sm:grid-cols-2"> | |
| 1318 | + <label className="block space-y-1"> | |
| 1319 | + <span className="text-[11px] font-medium text-muted-foreground">Name</span> | |
| 1320 | + <input | |
| 1321 | + value={newName} | |
| 1322 | + onChange={(e) => setNewName(e.target.value)} | |
| 1323 | + placeholder="API Key" | |
| 1324 | + className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1325 | + autoFocus | |
| 1326 | + /> | |
| 1327 | + </label> | |
| 1328 | + <label className="block space-y-1"> | |
| 1329 | + <span className="text-[11px] font-medium text-muted-foreground">Value</span> | |
| 1330 | + <input | |
| 1331 | + type="password" | |
| 1332 | + value={newValue} | |
| 1333 | + onChange={(e) => setNewValue(e.target.value)} | |
| 1334 | + placeholder="sk-..." | |
| 1335 | + className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1336 | + /> | |
| 1337 | + </label> | |
| 1338 | + </div> | |
| 1339 | + <div className="flex items-center justify-end gap-2"> | |
| 1340 | + <button | |
| 1341 | + type="button" | |
| 1342 | + onClick={() => setShowCreate(false)} | |
| 1343 | + className="rounded-md px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground" | |
| 1344 | + > | |
| 1345 | + Cancel | |
| 1346 | + </button> | |
| 1347 | + <Button | |
| 1348 | + size="sm" | |
| 1349 | + onClick={handleCreate} | |
| 1350 | + disabled={createSecret.status === "pending"} | |
| 1351 | + > | |
| 1352 | + {createSecret.status === "pending" && <IconSpinner className="size-3" />} | |
| 1353 | + Store & use | |
| 1354 | + </Button> | |
| 1355 | + </div> | |
| 1356 | + </div> | |
| 1357 | + )} | |
| 1358 | + </div> | |
| 1359 | + ); | |
| 1360 | +} | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { Link } from "@tanstack/react-router"; |
| 2 | 2 | import { useSources, type Source } from "@executor-v3/react"; |
| 3 | 3 | import { LoadableBlock } from "../components/loadable"; |
| 4 | +import { SourceFavicon } from "../components/source-favicon"; | |
| 4 | 5 | import { Badge } from "../components/ui/badge"; |
| 5 | 6 | import { Button } from "../components/ui/button"; |
| 6 | 7 | import { IconSources, IconPlus } from "../components/icons"; |
| @@ -21,7 +22,7 @@ export function HomePage() { · 14 unmodified lines | ||
| 21 | 22 | Connected tool providers in this workspace. |
| 22 | 23 | </p> |
| 23 | 24 | </div> |
| 24 | − <Link to="/sources/new"> | |
| 25 | + <Link to="/sources/add"> | |
| 25 | 26 | <Button size="sm"> |
| 26 | 27 | <IconPlus className="size-3.5" /> |
| 27 | 28 | Add source |
| @@ -43,7 +44,7 @@ export function HomePage() { · 15 unmodified lines | ||
| 43 | 44 | <p className="text-[13px] text-muted-foreground/60 mb-5"> |
| 44 | 45 | Add a source to get started. |
| 45 | 46 | </p> |
| 46 | − <Link to="/sources/new"> | |
| 47 | + <Link to="/sources/add"> | |
| 47 | 48 | <Button size="sm"> |
| 48 | 49 | <IconPlus className="size-3.5" /> |
| 49 | 50 | Add source |
| @@ -74,7 +75,7 @@ function SourceCard({ source }: { source: Source }) { · 24 unmodified lines | ||
| 74 | 75 | > |
| 75 | 76 | <div className="flex items-start justify-between gap-3 mb-3"> |
| 76 | 77 | <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary"> |
| 77 | − <IconSources className="size-4" /> | |
| 78 | + <SourceFavicon endpoint={source.endpoint} kind={source.kind} className="size-4.5" /> | |
| 78 | 79 | </div> |
| 79 | 80 | <Badge variant={source.status === "connected" ? "default" : source.status === "error" ? "destructive" : "muted"} className="shrink-0"> |
| 80 | 81 | {source.status} |
| @@ -1,26 +1,28 @@ | ||
| 1 | −import { useEffect, useState, type ReactNode } from "react"; | |
| 1 | +import { useState, type ReactNode } from "react"; | |
| 2 | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | import { |
| 4 | − type ConnectMcpSourcePayload, | |
| 4 | + type CompleteSourceOAuthResult, | |
| 5 | 5 | type CreateSourcePayload, |
| 6 | 6 | type InstanceConfig, |
| 7 | 7 | type Loadable, |
| 8 | + type StartSourceOAuthPayload, | |
| 8 | 9 | type SecretListItem, |
| 9 | 10 | type Source, |
| 10 | 11 | type UpdateSourcePayload, |
| 11 | − useConnectMcpSource, | |
| 12 | 12 | useCreateSecret, |
| 13 | 13 | useCreateSource, |
| 14 | − useInvalidateExecutorQueries, | |
| 15 | 14 | useInstanceConfig, |
| 15 | + useRefreshSecrets, | |
| 16 | 16 | useRemoveSource, |
| 17 | 17 | useSecrets, |
| 18 | + useStartSourceOAuth, | |
| 18 | 19 | useSource, |
| 19 | 20 | useUpdateSource, |
| 20 | 21 | } from "@executor-v3/react"; |
| 21 | 22 | import { Badge } from "../components/ui/badge"; |
| 22 | 23 | import { Button } from "../components/ui/button"; |
| 23 | 24 | import { LoadableBlock } from "../components/loadable"; |
| 25 | +import { SourceFavicon } from "../components/source-favicon"; | |
| 24 | 26 | import { |
| 25 | 27 | IconArrowLeft, |
| 26 | 28 | IconPencil, |
| @@ -29,6 +31,8 @@ import { · 2 unmodified lines | ||
| 29 | 31 | IconTrash, |
| 30 | 32 | } from "../components/icons"; |
| 31 | 33 | import { cn } from "../lib/utils"; |
| 34 | +import { sourceTemplates, type SourceTemplate } from "./source-templates"; | |
| 35 | +import { getDomain } from "tldts"; | |
| 32 | 36 | |
| 33 | 37 | type StatusBannerState = { |
| 34 | 38 | tone: "info" | "success" | "error"; |
| @@ -37,27 +41,20 @@ type StatusBannerState = { · 2 unmodified lines | ||
| 37 | 41 | |
| 38 | 42 | type SourceOAuthPopupMessage = |
| 39 | 43 | | { |
| 40 | − type: "executor-v3:source-oauth-result"; | |
| 44 | + type: "executor-v3:oauth-result"; | |
| 41 | 45 | ok: true; |
| 42 | − sourceId: string; | |
| 46 | + sessionId: string; | |
| 47 | + auth: CompleteSourceOAuthResult["auth"]; | |
| 43 | 48 | } |
| 44 | 49 | | { |
| 45 | − type: "executor-v3:source-oauth-result"; | |
| 50 | + type: "executor-v3:oauth-result"; | |
| 46 | 51 | ok: false; |
| 52 | + sessionId: string | null; | |
| 47 | 53 | error: string; |
| 48 | 54 | }; |
| 49 | 55 | |
| 50 | 56 | const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; |
| 51 | − | |
| 52 | −type SourceTemplate = { | |
| 53 | − id: string; | |
| 54 | − name: string; | |
| 55 | − summary: string; | |
| 56 | − kind: Source["kind"]; | |
| 57 | − endpoint: string; | |
| 58 | − specUrl?: string; | |
| 59 | − namespace?: string; | |
| 60 | −}; | |
| 57 | +const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor-v3:oauth-result:"; | |
| 61 | 58 | |
| 62 | 59 | type TransportValue = "" | NonNullable<Source["transport"]>; |
| 63 | 60 | |
| @@ -83,62 +80,6 @@ type SourceFormState = { · 19 unmodified lines | ||
| 83 | 80 | oauthRefreshHandle: string; |
| 84 | 81 | }; |
| 85 | 82 | |
| 86 | −const sourceTemplates: ReadonlyArray<SourceTemplate> = [ | |
| 87 | − { | |
| 88 | − id: "deepwiki-mcp", | |
| 89 | − name: "DeepWiki MCP", | |
| 90 | − summary: "Repository docs and knowledge graphs via MCP.", | |
| 91 | − kind: "mcp", | |
| 92 | − endpoint: "https://mcp.deepwiki.com/mcp", | |
| 93 | − namespace: "deepwiki", | |
| 94 | − }, | |
| 95 | − { | |
| 96 | − id: "github-rest", | |
| 97 | − name: "GitHub REST API", | |
| 98 | − summary: "Repos, issues, pull requests, actions, and org settings.", | |
| 99 | − kind: "openapi", | |
| 100 | − endpoint: "https://api.github.com", | |
| 101 | − specUrl: | |
| 102 | − "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", | |
| 103 | − namespace: "github", | |
| 104 | − }, | |
| 105 | − { | |
| 106 | − id: "openai-api", | |
| 107 | − name: "OpenAI API", | |
| 108 | − summary: "Models, files, responses, and fine-tuning.", | |
| 109 | − kind: "openapi", | |
| 110 | − endpoint: "https://api.openai.com/v1", | |
| 111 | − specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", | |
| 112 | − namespace: "openai", | |
| 113 | − }, | |
| 114 | − { | |
| 115 | − id: "vercel-api", | |
| 116 | − name: "Vercel API", | |
| 117 | − summary: "Deployments, projects, domains, and environments.", | |
| 118 | − kind: "openapi", | |
| 119 | − endpoint: "https://api.vercel.com", | |
| 120 | − specUrl: "https://openapi.vercel.sh", | |
| 121 | − namespace: "vercel", | |
| 122 | − }, | |
| 123 | − { | |
| 124 | − id: "stripe-api", | |
| 125 | − name: "Stripe API", | |
| 126 | − summary: "Payments, billing, subscriptions, and invoices.", | |
| 127 | − kind: "openapi", | |
| 128 | − endpoint: "https://api.stripe.com", | |
| 129 | − specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", | |
| 130 | − namespace: "stripe", | |
| 131 | − }, | |
| 132 | − { | |
| 133 | − id: "linear-graphql", | |
| 134 | − name: "Linear GraphQL", | |
| 135 | − summary: "Issues, teams, cycles, and projects.", | |
| 136 | − kind: "graphql", | |
| 137 | − endpoint: "https://api.linear.app/graphql", | |
| 138 | − namespace: "linear", | |
| 139 | − }, | |
| 140 | −]; | |
| 141 | − | |
| 142 | 83 | const kindOptions: ReadonlyArray<Source["kind"]> = ["mcp", "openapi", "graphql", "internal"]; |
| 143 | 84 | |
| 144 | 85 | const transportOptions: ReadonlyArray<NonNullable<Source["transport"]>> = [ |
| @@ -154,13 +95,56 @@ const trimToNull = (value: string): string | null => { · 9 unmodified lines | ||
| 154 | 95 | return trimmed.length > 0 ? trimmed : null; |
| 155 | 96 | }; |
| 156 | 97 | |
| 157 | −const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => { | |
| 98 | +const namespaceFromUrl = (url: string): string => { | |
| 99 | + try { | |
| 100 | + const domain = getDomain(url); | |
| 101 | + if (!domain) return ""; | |
| 102 | + const dot = domain.indexOf("."); | |
| 103 | + return dot > 0 ? domain.slice(0, dot) : domain; | |
| 104 | + } catch { | |
| 105 | + return ""; | |
| 106 | + } | |
| 107 | +}; | |
| 108 | + | |
| 109 | +const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => { | |
| 110 | + if (typeof window === "undefined") { | |
| 111 | + return null; | |
| 112 | + } | |
| 113 | + | |
| 114 | + const raw = window.localStorage.getItem( | |
| 115 | + `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`, | |
| 116 | + ); | |
| 117 | + if (!raw) { | |
| 118 | + return null; | |
| 119 | + } | |
| 120 | + | |
| 121 | + try { | |
| 122 | + return JSON.parse(raw) as SourceOAuthPopupMessage; | |
| 123 | + } catch { | |
| 124 | + return null; | |
| 125 | + } | |
| 126 | +}; | |
| 127 | + | |
| 128 | +const clearStoredSourceOAuthPopupResult = (sessionId: string): void => { | |
| 129 | + if (typeof window === "undefined") { | |
| 130 | + return; | |
| 131 | + } | |
| 132 | + | |
| 133 | + window.localStorage.removeItem(`${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`); | |
| 134 | +}; | |
| 135 | + | |
| 136 | +const startSourceOAuthPopup = async (input: { | |
| 137 | + authorizationUrl: string; | |
| 138 | + sessionId: string; | |
| 139 | +}): Promise<CompleteSourceOAuthResult["auth"]> => { | |
| 158 | 140 | if (typeof window === "undefined") { |
| 159 | 141 | throw new Error("OAuth popup is only available in a browser context"); |
| 160 | 142 | } |
| 161 | 143 | |
| 144 | + clearStoredSourceOAuthPopupResult(input.sessionId); | |
| 145 | + | |
| 162 | 146 | const popup = window.open( |
| 163 | − authorizationUrl, | |
| 147 | + input.authorizationUrl, | |
| 164 | 148 | "executor-v3-source-oauth", |
| 165 | 149 | "popup=yes,width=520,height=720", |
| 166 | 150 | ); |
| @@ -171,7 +155,7 @@ const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => · 4 unmodified lines | ||
| 171 | 155 | |
| 172 | 156 | popup.focus(); |
| 173 | 157 | |
| 174 | − await new Promise<void>((resolve, reject) => { | |
| 158 | + return await new Promise<CompleteSourceOAuthResult["auth"]>((resolve, reject) => { | |
| 175 | 159 | let settled = false; |
| 176 | 160 | let closedPoll = 0; |
| 177 | 161 | let resultTimeout = 0; |
| @@ -187,6 +171,7 @@ const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => · 9 unmodified lines | ||
| 187 | 171 | if (!popup.closed) { |
| 188 | 172 | popup.close(); |
| 189 | 173 | } |
| 174 | + clearStoredSourceOAuthPopupResult(input.sessionId); | |
| 190 | 175 | }; |
| 191 | 176 | |
| 192 | 177 | const settleWithError = (message: string) => { |
| @@ -199,28 +184,40 @@ const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => · 6 unmodified lines | ||
| 199 | 184 | reject(new Error(message)); |
| 200 | 185 | }; |
| 201 | 186 | |
| 187 | + const settleFromPayload = (data: SourceOAuthPopupMessage) => { | |
| 188 | + if (!data.ok) { | |
| 189 | + settleWithError(data.error || "OAuth failed"); | |
| 190 | + return; | |
| 191 | + } | |
| 192 | + | |
| 193 | + if (settled) { | |
| 194 | + return; | |
| 195 | + } | |
| 196 | + | |
| 197 | + settled = true; | |
| 198 | + cleanup(); | |
| 199 | + resolve(data.auth); | |
| 200 | + }; | |
| 201 | + | |
| 202 | 202 | const onMessage = (event: MessageEvent) => { |
| 203 | 203 | if (event.origin !== window.location.origin) { |
| 204 | 204 | return; |
| 205 | 205 | } |
| 206 | 206 | |
| 207 | 207 | const data = event.data as SourceOAuthPopupMessage | undefined; |
| 208 | − if (!data || data.type !== "executor-v3:source-oauth-result") { | |
| 208 | + if (!data || data.type !== "executor-v3:oauth-result") { | |
| 209 | 209 | return; |
| 210 | 210 | } |
| 211 | 211 | |
| 212 | − if (!data.ok) { | |
| 213 | − settleWithError(data.error || "OAuth failed"); | |
| 212 | + if (data.ok && data.sessionId !== input.sessionId) { | |
| 214 | 213 | return; |
| 215 | 214 | } |
| 216 | 215 | |
| 217 | − if (settled) { | |
| 216 | + if (!data.ok && data.sessionId !== null && data.sessionId !== input.sessionId) { | |
| 218 | 217 | return; |
| 219 | 218 | } |
| 220 | 219 | |
| 221 | − settled = true; | |
| 222 | − cleanup(); | |
| 223 | − resolve(); | |
| 220 | + settleFromPayload(data); | |
| 224 | 221 | }; |
| 225 | 222 | |
| 226 | 223 | window.addEventListener("message", onMessage); |
| @@ -230,8 +227,22 @@ const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => · 3 unmodified lines | ||
| 230 | 227 | }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS); |
| 231 | 228 | |
| 232 | 229 | closedPoll = window.setInterval(() => { |
| 230 | + const stored = readStoredSourceOAuthPopupResult(input.sessionId); | |
| 231 | + if (stored) { | |
| 232 | + settleFromPayload(stored); | |
| 233 | + return; | |
| 234 | + } | |
| 235 | + | |
| 233 | 236 | if (popup.closed) { |
| 234 | − settleWithError("OAuth popup was closed before completion."); | |
| 237 | + window.setTimeout(() => { | |
| 238 | + const delayedStored = readStoredSourceOAuthPopupResult(input.sessionId); | |
| 239 | + if (delayedStored) { | |
| 240 | + settleFromPayload(delayedStored); | |
| 241 | + return; | |
| 242 | + } | |
| 243 | + | |
| 244 | + settleWithError("OAuth popup was closed before completion."); | |
| 245 | + }, 400); | |
| 235 | 246 | } |
| 236 | 247 | }, 300); |
| 237 | 248 | }); |
| @@ -244,12 +255,12 @@ const defaultFormState = (template?: SourceTemplate): SourceFormState => ({ · 6 unmodified lines | ||
| 244 | 255 | name: template?.name ?? "", |
| 245 | 256 | kind: template?.kind ?? "openapi", |
| 246 | 257 | endpoint: template?.endpoint ?? "", |
| 247 | − namespace: template?.namespace ?? "", | |
| 258 | + namespace: template ? namespaceFromUrl(template.endpoint) : "", | |
| 248 | 259 | enabled: true, |
| 249 | 260 | transport: template?.kind === "mcp" ? "auto" : "", |
| 250 | 261 | queryParamsText: "", |
| 251 | 262 | headersText: "", |
| 252 | − specUrl: template?.specUrl ?? "", | |
| 263 | + specUrl: "", | |
| 253 | 264 | defaultHeadersText: "", |
| 254 | 265 | authKind: "none", |
| 255 | 266 | authHeaderName: "Authorization", |
| @@ -375,6 +386,14 @@ const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] = · 119 unmodified lines | ||
| 375 | 386 | }; |
| 376 | 387 | }; |
| 377 | 388 | |
| 389 | +const buildRequestedSourceStatus = (state: SourceFormState): CreateSourcePayload["status"] => { | |
| 390 | + if (state.kind !== "mcp" && state.kind !== "openapi" && state.kind !== "graphql") { | |
| 391 | + return undefined; | |
| 392 | + } | |
| 393 | + | |
| 394 | + return state.enabled ? "connected" : "draft"; | |
| 395 | +}; | |
| 396 | + | |
| 378 | 397 | const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { |
| 379 | 398 | const name = state.name.trim(); |
| 380 | 399 | const endpoint = state.endpoint.trim(); |
| @@ -391,10 +410,11 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 10 unmodified lines | ||
| 391 | 410 | name, |
| 392 | 411 | kind: state.kind, |
| 393 | 412 | endpoint, |
| 413 | + status: buildRequestedSourceStatus(state), | |
| 394 | 414 | enabled: state.enabled, |
| 395 | 415 | namespace: trimToNull(state.namespace), |
| 396 | 416 | auth: buildAuthPayload(state), |
| 397 | − } satisfies Pick<CreateSourcePayload, "name" | "kind" | "endpoint" | "enabled" | "namespace" | "auth">; | |
| 417 | + } satisfies Pick<CreateSourcePayload, "name" | "kind" | "endpoint" | "status" | "enabled" | "namespace" | "auth">; | |
| 398 | 418 | |
| 399 | 419 | if (state.kind === "mcp") { |
| 400 | 420 | return { |
| @@ -423,6 +443,17 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 22 unmodified lines | ||
| 423 | 443 | }; |
| 424 | 444 | } |
| 425 | 445 | |
| 446 | + if (state.kind === "graphql") { | |
| 447 | + return { | |
| 448 | + ...shared, | |
| 449 | + transport: null, | |
| 450 | + queryParams: null, | |
| 451 | + headers: null, | |
| 452 | + specUrl: null, | |
| 453 | + defaultHeaders: parseJsonStringMap("Default headers", state.defaultHeadersText), | |
| 454 | + }; | |
| 455 | + } | |
| 456 | + | |
| 426 | 457 | return { |
| 427 | 458 | ...shared, |
| 428 | 459 | transport: null, |
| @@ -437,10 +468,7 @@ const buildUpdatePayload = (state: SourceFormState): UpdateSourcePayload => ({ · 8 unmodified lines | ||
| 437 | 468 | ...buildSourcePayload(state), |
| 438 | 469 | }); |
| 439 | 470 | |
| 440 | −const buildMcpConnectPayload = ( | |
| 441 | − state: SourceFormState, | |
| 442 | − sourceId?: Source["id"], | |
| 443 | −): ConnectMcpSourcePayload => { | |
| 471 | +const buildStartSourceOAuthPayload = (state: SourceFormState): StartSourceOAuthPayload => { | |
| 444 | 472 | if (state.kind !== "mcp") { |
| 445 | 473 | throw new Error("OAuth sign-in is only available for MCP sources."); |
| 446 | 474 | } |
| @@ -451,11 +479,9 @@ const buildMcpConnectPayload = ( · 4 unmodified lines | ||
| 451 | 479 | } |
| 452 | 480 | |
| 453 | 481 | return { |
| 454 | − ...(sourceId ? { sourceId } : {}), | |
| 455 | − endpoint, | |
| 482 | + provider: "mcp", | |
| 456 | 483 | name: trimToNull(state.name), |
| 457 | − namespace: trimToNull(state.namespace), | |
| 458 | − enabled: state.enabled, | |
| 484 | + endpoint, | |
| 459 | 485 | transport: state.transport === "" ? "auto" : state.transport, |
| 460 | 486 | queryParams: parseJsonStringMap("Query params", state.queryParamsText), |
| 461 | 487 | headers: parseJsonStringMap("Request headers", state.headersText), |
| @@ -463,7 +489,7 @@ const buildMcpConnectPayload = ( · 1 unmodified line | ||
| 463 | 489 | }; |
| 464 | 490 | |
| 465 | 491 | export function NewSourcePage() { |
| 466 | − return <SourceEditor mode="create" />; | |
| 492 | + return <SourceEditor key="create" mode="create" />; | |
| 467 | 493 | } |
| 468 | 494 | |
| 469 | 495 | export function EditSourcePage(props: { sourceId: string }) { |
| @@ -471,7 +497,13 @@ export function EditSourcePage(props: { sourceId: string }) { · 1 unmodified line | ||
| 471 | 497 | |
| 472 | 498 | return ( |
| 473 | 499 | <LoadableBlock loadable={source} loading="Loading source..."> |
| 474 | − {(loadedSource) => <SourceEditor mode="edit" source={loadedSource} />} | |
| 500 | + {(loadedSource) => ( | |
| 501 | + <SourceEditor | |
| 502 | + key={`${loadedSource.id}:${loadedSource.updatedAt}`} | |
| 503 | + mode="edit" | |
| 504 | + source={loadedSource} | |
| 505 | + /> | |
| 506 | + )} | |
| 475 | 507 | </LoadableBlock> |
| 476 | 508 | ); |
| 477 | 509 | } |
| @@ -479,29 +511,29 @@ export function EditSourcePage(props: { sourceId: string }) { · 1 unmodified line | ||
| 479 | 511 | function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { |
| 480 | 512 | const navigate = useNavigate(); |
| 481 | 513 | const createSource = useCreateSource(); |
| 482 | − const connectMcpSource = useConnectMcpSource(); | |
| 514 | + const startSourceOAuth = useStartSourceOAuth(); | |
| 483 | 515 | const updateSource = useUpdateSource(); |
| 484 | 516 | const removeSource = useRemoveSource(); |
| 485 | − const invalidateQueries = useInvalidateExecutorQueries(); | |
| 486 | 517 | const instanceConfig = useInstanceConfig(); |
| 487 | 518 | const secrets = useSecrets(); |
| 519 | + const refreshSecrets = useRefreshSecrets(); | |
| 488 | 520 | const [formState, setFormState] = useState<SourceFormState>(() => |
| 489 | 521 | props.source ? formStateFromSource(props.source) : defaultFormState(), |
| 490 | 522 | ); |
| 491 | 523 | const [statusBanner, setStatusBanner] = useState<StatusBannerState | null>(null); |
| 492 | 524 | const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null); |
| 493 | 525 | const [oauthPopupBusy, setOauthPopupBusy] = useState(false); |
| 494 | − | |
| 495 | − useEffect(() => { | |
| 496 | − if (props.source) { | |
| 497 | − setFormState(formStateFromSource(props.source)); | |
| 498 | − setStatusBanner(null); | |
| 499 | − } | |
| 500 | − }, [props.source]); | |
| 526 | + const [expandedOauthSecretRefTarget, setExpandedOauthSecretRefTarget] = useState<string | null>(null); | |
| 501 | 527 | |
| 502 | 528 | const isSubmitting = createSource.status === "pending" || updateSource.status === "pending"; |
| 503 | 529 | const isDeleting = removeSource.status === "pending"; |
| 504 | − const isOAuthSubmitting = connectMcpSource.status === "pending" || oauthPopupBusy; | |
| 530 | + const isOAuthSubmitting = startSourceOAuth.status === "pending" || oauthPopupBusy; | |
| 531 | + const oauthSecretRefTarget = | |
| 532 | + formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0 | |
| 533 | + ? `${formState.oauthAccessProviderId}:${formState.oauthAccessHandle}:${formState.oauthRefreshProviderId}:${formState.oauthRefreshHandle}` | |
| 534 | + : null; | |
| 535 | + const showOauthSecretRefs = | |
| 536 | + oauthSecretRefTarget !== null && expandedOauthSecretRefTarget === oauthSecretRefTarget; | |
| 505 | 537 | |
| 506 | 538 | const setField = <K extends keyof SourceFormState>(key: K, value: SourceFormState[K]) => { |
| 507 | 539 | setFormState((current) => ({ ...current, [key]: value })); |
| @@ -513,15 +545,6 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 5 unmodified lines | ||
| 513 | 545 | ...defaultFormState(template), |
| 514 | 546 | name: current.name.trim().length > 0 ? current.name : template.name, |
| 515 | 547 | enabled: current.enabled, |
| 516 | − authKind: current.authKind, | |
| 517 | − authHeaderName: current.authHeaderName, | |
| 518 | − authPrefix: current.authPrefix, | |
| 519 | − bearerProviderId: current.bearerProviderId, | |
| 520 | − bearerHandle: current.bearerHandle, | |
| 521 | − oauthAccessProviderId: current.oauthAccessProviderId, | |
| 522 | − oauthAccessHandle: current.oauthAccessHandle, | |
| 523 | − oauthRefreshProviderId: current.oauthRefreshProviderId, | |
| 524 | − oauthRefreshHandle: current.oauthRefreshHandle, | |
| 525 | 548 | })); |
| 526 | 549 | setStatusBanner({ |
| 527 | 550 | tone: "info", |
| @@ -568,33 +591,34 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 40 unmodified lines | ||
| 568 | 591 | setStatusBanner(null); |
| 569 | 592 | |
| 570 | 593 | try { |
| 571 | − const result = await connectMcpSource.mutateAsync( | |
| 572 | − buildMcpConnectPayload(formState, props.source?.id), | |
| 573 | − ); | |
| 574 | − | |
| 575 | − if (result.kind === "connected") { | |
| 576 | − invalidateQueries(); | |
| 577 | − void navigate({ | |
| 578 | − to: "/sources/$sourceId", | |
| 579 | − params: { sourceId: result.source.id }, | |
| 580 | − search: { tab: "model" }, | |
| 581 | − }); | |
| 582 | − return; | |
| 583 | − } | |
| 594 | + const result = await startSourceOAuth.mutateAsync(buildStartSourceOAuthPayload(formState)); | |
| 584 | 595 | |
| 585 | 596 | setStatusBanner({ |
| 586 | 597 | tone: "info", |
| 587 | − text: `Finish OAuth in the popup to connect ${result.source.name}.`, | |
| 598 | + text: "Finish OAuth in the popup. Saving will create the source and connect its tools.", | |
| 588 | 599 | }); |
| 589 | 600 | |
| 590 | 601 | setOauthPopupBusy(true); |
| 591 | − await startSourceOAuthPopup(result.authorizationUrl); | |
| 602 | + const auth = await startSourceOAuthPopup({ | |
| 603 | + authorizationUrl: result.authorizationUrl, | |
| 604 | + sessionId: result.sessionId, | |
| 605 | + }); | |
| 592 | 606 | setOauthPopupBusy(false); |
| 593 | − invalidateQueries(); | |
| 594 | − void navigate({ | |
| 595 | − to: "/sources/$sourceId", | |
| 596 | − params: { sourceId: result.source.id }, | |
| 597 | − search: { tab: "model" }, | |
| 607 | + refreshSecrets(); | |
| 608 | + setExpandedOauthSecretRefTarget(null); | |
| 609 | + setFormState((current) => ({ | |
| 610 | + ...current, | |
| 611 | + authKind: "oauth2", | |
| 612 | + authHeaderName: auth.headerName, | |
| 613 | + authPrefix: auth.prefix, | |
| 614 | + oauthAccessProviderId: auth.accessToken.providerId, | |
| 615 | + oauthAccessHandle: auth.accessToken.handle, | |
| 616 | + oauthRefreshProviderId: auth.refreshToken?.providerId ?? "", | |
| 617 | + oauthRefreshHandle: auth.refreshToken?.handle ?? "", | |
| 618 | + })); | |
| 619 | + setStatusBanner({ | |
| 620 | + tone: "success", | |
| 621 | + text: "OAuth credentials are ready. Save the source to connect and index tools.", | |
| 598 | 622 | }); |
| 599 | 623 | } catch (error) { |
| 600 | 624 | setOauthPopupBusy(false); |
| @@ -677,8 +701,13 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 76 unmodified lines | ||
| 677 | 701 | : "border-border bg-card/70 hover:bg-accent/50", |
| 678 | 702 | )} |
| 679 | 703 | > |
| 680 | − <div className="flex items-center justify-between gap-2 mb-1"> | |
| 681 | − <span className="text-[13px] font-medium text-foreground">{template.name}</span> | |
| 704 | + <div className="mb-1 flex items-center justify-between gap-2"> | |
| 705 | + <div className="flex min-w-0 items-center gap-2"> | |
| 706 | + <div className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"> | |
| 707 | + <SourceFavicon endpoint={template.endpoint} kind={template.kind} className="size-4" /> | |
| 708 | + </div> | |
| 709 | + <span className="truncate text-[13px] font-medium text-foreground">{template.name}</span> | |
| 710 | + </div> | |
| 682 | 711 | <Badge variant="outline" className="text-[9px]">{template.kind}</Badge> |
| 683 | 712 | </div> |
| 684 | 713 | <span className="text-[11px] text-muted-foreground line-clamp-1"> |
| @@ -715,7 +744,13 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 30 unmodified lines | ||
| 715 | 744 | <TextInput |
| 716 | 745 | value={formState.endpoint} |
| 717 | 746 | onChange={(value) => setField("endpoint", value)} |
| 718 | − placeholder={formState.kind === "openapi" ? "https://api.github.com" : "https://mcp.deepwiki.com/mcp"} | |
| 747 | + placeholder={ | |
| 748 | + formState.kind === "openapi" | |
| 749 | + ? "https://api.github.com" | |
| 750 | + : formState.kind === "graphql" | |
| 751 | + ? "https://api.linear.app/graphql" | |
| 752 | + : "https://mcp.deepwiki.com/mcp" | |
| 753 | + } | |
| 719 | 754 | mono |
| 720 | 755 | /> |
| 721 | 756 | </Field> |
| @@ -787,6 +822,20 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 65 unmodified lines | ||
| 787 | 822 | </Section> |
| 788 | 823 | )} |
| 789 | 824 | |
| 825 | + {formState.kind === "graphql" && ( | |
| 826 | + <Section title="GraphQL"> | |
| 827 | + <div className="grid gap-4"> | |
| 828 | + <Field label="Default headers (JSON)"> | |
| 829 | + <CodeEditor | |
| 830 | + value={formState.defaultHeadersText} | |
| 831 | + onChange={(value) => setField("defaultHeadersText", value)} | |
| 832 | + placeholder={'{\n "x-api-version": "2026-03-01"\n}'} | |
| 833 | + /> | |
| 834 | + </Field> | |
| 835 | + </div> | |
| 836 | + </Section> | |
| 837 | + )} | |
| 838 | + | |
| 790 | 839 | <Section title="Authentication"> |
| 791 | 840 | <div className="grid gap-4 sm:grid-cols-2"> |
| 792 | 841 | {formState.kind === "mcp" && ( |
| @@ -805,9 +854,28 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 12 unmodified lines | ||
| 805 | 854 | disabled={isSubmitting || isDeleting || isOAuthSubmitting} |
| 806 | 855 | > |
| 807 | 856 | {isOAuthSubmitting ? <IconSpinner className="size-3.5" /> : null} |
| 808 | − {props.source?.auth.kind === "oauth2" ? "Reconnect with OAuth" : "Sign in with OAuth"} | |
| 857 | + {formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0 | |
| 858 | + ? "Reconnect with OAuth" | |
| 859 | + : "Sign in with OAuth"} | |
| 809 | 860 | </Button> |
| 810 | 861 | </div> |
| 862 | + {formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0 && ( | |
| 863 | + <div className="mt-3 flex items-center justify-between gap-3 border-t border-border pt-3"> | |
| 864 | + <p className="text-[12px] text-muted-foreground"> | |
| 865 | + OAuth tokens are attached to this draft and stay hidden unless you need the raw refs. | |
| 866 | + </p> | |
| 867 | + <button | |
| 868 | + type="button" | |
| 869 | + className="text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground" | |
| 870 | + onClick={() => | |
| 871 | + setExpandedOauthSecretRefTarget((current) => | |
| 872 | + current === oauthSecretRefTarget ? null : oauthSecretRefTarget, | |
| 873 | + )} | |
| 874 | + > | |
| 875 | + {showOauthSecretRefs ? "Hide token refs" : "Show token refs"} | |
| 876 | + </button> | |
| 877 | + </div> | |
| 878 | + )} | |
| 811 | 879 | </div> |
| 812 | 880 | )} |
| 813 | 881 | <Field label="Auth mode"> |
| @@ -850,7 +918,10 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 36 unmodified lines | ||
| 850 | 918 | </Field> |
| 851 | 919 | )} |
| 852 | 920 | |
| 853 | − {formState.authKind === "oauth2" && ( | |
| 921 | + {formState.authKind === "oauth2" | |
| 922 | + && (formState.kind !== "mcp" | |
| 923 | + || formState.oauthAccessHandle.trim().length === 0 | |
| 924 | + || showOauthSecretRefs) && ( | |
| 854 | 925 | <> |
| 855 | 926 | <Field label="Access token" className="sm:col-span-2"> |
| 856 | 927 | <SecretPicker |
| @@ -0,0 +1,89 @@ | ||
| 1 | +import type { Source } from "@executor-v3/react"; | |
| 2 | + | |
| 3 | +export type SourceTemplate = { | |
| 4 | + id: string; | |
| 5 | + name: string; | |
| 6 | + summary: string; | |
| 7 | + kind: Source["kind"]; | |
| 8 | + endpoint: string; | |
| 9 | +}; | |
| 10 | + | |
| 11 | +export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ | |
| 12 | + { | |
| 13 | + id: "deepwiki-mcp", | |
| 14 | + name: "DeepWiki MCP", | |
| 15 | + summary: "Repository docs and knowledge graphs via MCP.", | |
| 16 | + kind: "mcp", | |
| 17 | + endpoint: "https://mcp.deepwiki.com/mcp", | |
| 18 | + }, | |
| 19 | + { | |
| 20 | + id: "axiom-mcp", | |
| 21 | + name: "Axiom MCP", | |
| 22 | + summary: "Query, stream, and analyze logs, traces, and event data.", | |
| 23 | + kind: "mcp", | |
| 24 | + endpoint: "https://mcp.axiom.co/mcp", | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + id: "github-rest", | |
| 28 | + name: "GitHub REST API", | |
| 29 | + summary: "Repos, issues, pull requests, actions, and org settings.", | |
| 30 | + kind: "openapi", | |
| 31 | + endpoint: "https://api.github.com", | |
| 32 | + }, | |
| 33 | + { | |
| 34 | + id: "github-graphql", | |
| 35 | + name: "GitHub GraphQL", | |
| 36 | + summary: "Issues, pull requests, discussions, and repository objects via GraphQL.", | |
| 37 | + kind: "graphql", | |
| 38 | + endpoint: "https://api.github.com/graphql", | |
| 39 | + }, | |
| 40 | + { | |
| 41 | + id: "gitlab-graphql", | |
| 42 | + name: "GitLab GraphQL", | |
| 43 | + summary: "Projects, merge requests, issues, CI pipelines, and users.", | |
| 44 | + kind: "graphql", | |
| 45 | + endpoint: "https://gitlab.com/api/graphql", | |
| 46 | + }, | |
| 47 | + { | |
| 48 | + id: "openai-api", | |
| 49 | + name: "OpenAI API", | |
| 50 | + summary: "Models, files, responses, and fine-tuning.", | |
| 51 | + kind: "openapi", | |
| 52 | + endpoint: "https://api.openai.com/v1", | |
| 53 | + }, | |
| 54 | + { | |
| 55 | + id: "vercel-api", | |
| 56 | + name: "Vercel API", | |
| 57 | + summary: "Deployments, projects, domains, and environments.", | |
| 58 | + kind: "openapi", | |
| 59 | + endpoint: "https://api.vercel.com", | |
| 60 | + }, | |
| 61 | + { | |
| 62 | + id: "stripe-api", | |
| 63 | + name: "Stripe API", | |
| 64 | + summary: "Payments, billing, subscriptions, and invoices.", | |
| 65 | + kind: "openapi", | |
| 66 | + endpoint: "https://api.stripe.com", | |
| 67 | + }, | |
| 68 | + { | |
| 69 | + id: "linear-graphql", | |
| 70 | + name: "Linear GraphQL", | |
| 71 | + summary: "Issues, teams, cycles, and projects.", | |
| 72 | + kind: "graphql", | |
| 73 | + endpoint: "https://api.linear.app/graphql", | |
| 74 | + }, | |
| 75 | + { | |
| 76 | + id: "monday-graphql", | |
| 77 | + name: "Monday GraphQL", | |
| 78 | + summary: "Boards, items, updates, users, and workspace metadata.", | |
| 79 | + kind: "graphql", | |
| 80 | + endpoint: "https://api.monday.com/v2", | |
| 81 | + }, | |
| 82 | + { | |
| 83 | + id: "anilist-graphql", | |
| 84 | + name: "AniList GraphQL", | |
| 85 | + summary: "Anime, manga, characters, media lists, and recommendations.", | |
| 86 | + kind: "graphql", | |
| 87 | + endpoint: "https://graphql.anilist.co", | |
| 88 | + }, | |
| 89 | +]; | |
| @@ -12,6 +12,7 @@ · 11 unmodified lines | ||
| 12 | 12 | }, |
| 13 | 13 | "apps/executor": { |
| 14 | 14 | "name": "executor", |
| 15 | + "version": "0.0.0-local", | |
| 15 | 16 | "bin": { |
| 16 | 17 | "executor": "./bin/executor", |
| 17 | 18 | }, |
| @@ -63,6 +64,7 @@ · 45 unmodified lines | ||
| 63 | 64 | "shiki": "^4.0.1", |
| 64 | 65 | "streamdown": "^2.3.0", |
| 65 | 66 | "tailwind-merge": "^3.5.0", |
| 67 | + "tldts": "^7.0.23", | |
| 66 | 68 | }, |
| 67 | 69 | "devDependencies": { |
| 68 | 70 | "@hono/vite-dev-server": "^0.25.0", |
| @@ -147,6 +149,7 @@ · 78 unmodified lines | ||
| 147 | 149 | "@executor-v3/runtime-local-inproc": "workspace:*", |
| 148 | 150 | "drizzle-orm": "1.0.0-beta.15-859cf75", |
| 149 | 151 | "effect": "catalog:", |
| 152 | + "graphql": "^16.12.0", | |
| 150 | 153 | "postgres": "^3.4.7", |
| 151 | 154 | "prettier": "^3.8.1", |
| 152 | 155 | }, |
| @@ -974,6 +977,8 @@ · 821 unmodified lines | ||
| 974 | 977 | |
| 975 | 978 | "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], |
| 976 | 979 | |
| 980 | + "graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="], | |
| 981 | + | |
| 977 | 982 | "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], |
| 978 | 983 | |
| 979 | 984 | "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], |
| @@ -1502,9 +1507,9 @@ · 522 unmodified lines | ||
| 1502 | 1507 | |
| 1503 | 1508 | "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], |
| 1504 | 1509 | |
| 1505 | − "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], | |
| 1510 | + "tldts": ["tldts@7.0.25", "", { "dependencies": { "tldts-core": "^7.0.25" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w=="], | |
| 1506 | 1511 | |
| 1507 | − "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], | |
| 1512 | + "tldts-core": ["tldts-core@7.0.25", "", {}, "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw=="], | |
| 1508 | 1513 | |
| 1509 | 1514 | "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], |
| 1510 | 1515 | |
| @@ -1650,6 +1655,8 @@ · 139 unmodified lines | ||
| 1650 | 1655 | |
| 1651 | 1656 | "tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], |
| 1652 | 1657 | |
| 1658 | + "tough-cookie/tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], | |
| 1659 | + | |
| 1653 | 1660 | "vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], |
| 1654 | 1661 | |
| 1655 | 1662 | "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], |
| @@ -1666,6 +1673,8 @@ · 10 unmodified lines | ||
| 1666 | 1673 | |
| 1667 | 1674 | "@streamdown/code/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], |
| 1668 | 1675 | |
| 1676 | + "tough-cookie/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], | |
| 1677 | + | |
| 1669 | 1678 | "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], |
| 1670 | 1679 | |
| 1671 | 1680 | "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], |