History

add discovery-first Add Source wizard with favicons and GraphQL support

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.

Rhys Sullivan committed 8 months ago

Commit d855f0e1 parent 96d6c3711 files · +1888 −155Browse files

Unified · Split

11 files
  1. apps/web/package.json +2 −1
  2. apps/web/src/components/shell.tsx +6 −3
  3. apps/web/src/components/source-favicon.tsx +81 −0
  4. apps/web/src/dev.ts +52 −4
  5. apps/web/src/lib/source-favicon.ts +63 −0
  6. apps/web/src/main.tsx +8 −1
  7. apps/web/src/views/add-source.tsx +1360 −0
  8. apps/web/src/views/home.tsx +4 −3
  9. apps/web/src/views/source-editor.tsx +212 −141
  10. apps/web/src/views/source-templates.ts +89 −0
  11. bun.lock +11 −2
apps/web/package.json+2−1
@@ -22,7 +22,8 @@ · 21 unmodified lines
2222 "scheduler": "^0.27.0",
2323 "shiki": "^4.0.1",
2424 "streamdown": "^2.3.0",
25− "tailwind-merge": "^3.5.0"
25+ "tailwind-merge": "^3.5.0",
26+ "tldts": "^7.0.23"
2627 },
2728 "devDependencies": {
2829 "@hono/vite-dev-server": "^0.25.0",
apps/web/src/components/shell.tsx+6−3
@@ -1,8 +1,9 @@
11 import { Link, Outlet, useMatchRoute } from "@tanstack/react-router";
22 import { useSources, type Source } from "@executor-v3/react";
33 import { cn } from "../lib/utils";
4−import { IconPlus, IconSources } from "./icons";
4+import { IconPlus } from "./icons";
55 import { LoadableBlock } from "./loadable";
6+import { SourceFavicon } from "./source-favicon";
67
78 // ── Status dot color ─────────────────────────────────────────────────────
89
@@ -47,7 +48,7 @@ export function AppShell() { · 38 unmodified lines
4748 <div className="flex items-center justify-between gap-2">
4849 <span>Sources</span>
4950 <Link
50− to="/sources/new"
51+ to="/sources/add"
5152 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"
5253 >
5354 <IconPlus className="size-3" />
@@ -113,7 +114,9 @@ function SourceItem(props: { · 59 unmodified lines
113114 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
114115 )}
115116 >
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>
117120 <span className="flex-1 truncate">{source.name}</span>
118121 <span
119122 className={cn("size-1.5 shrink-0 rounded-full", statusColor[source.status] ?? "bg-muted-foreground/30")}
apps/web/src/components/source-favicon.tsx+81−0
@@ -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+}
apps/web/src/dev.ts+52−4
@@ -9,6 +9,22 @@ import * as Effect from "effect/Effect"; · 8 unmodified lines
99 import * as Scope from "effect/Scope";
1010 import { createLocalExecutorRequestHandler } from "@executor-v3/server";
1111
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+
1228 // Create a long-lived scope that stays open for the lifetime of the process.
1329 const handlerPromise = Effect.runPromise(
1430 Effect.gen(function* () {
@@ -16,14 +32,46 @@ const handlerPromise = Effect.runPromise( · 1 unmodified line
1632 const handler = yield* createLocalExecutorRequestHandler().pipe(
1733 Effect.provideService(Scope.Scope, scope),
1834 );
19− handler.setBaseUrl("http://127.0.0.1:8788");
2035 return handler;
2136 }),
22−);
37+).catch((error) => {
38+ console.error("[executor dev api] failed to initialize request handler", formatErrorForLog(error));
39+ throw error;
40+});
2341
2442 export default {
2543 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+ }
2876 },
2977 };
apps/web/src/lib/source-favicon.ts+63−0
@@ -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+};
apps/web/src/main.tsx+8−1
@@ -15,6 +15,7 @@ import { HomePage } from "./views/home"; · 14 unmodified lines
1515 import { EditSourcePage, NewSourcePage } from "./views/source-editor";
1616 import { SourceDetailPage } from "./views/source-detail";
1717 import { SecretsPage } from "./views/secrets";
18+import { AddSourcePage } from "./views/add-source";
1819
1920 // ---------------------------------------------------------------------------
2021 // Route search schema
@@ -48,6 +49,12 @@ const newSourceRoute = createRoute({ · 27 unmodified lines
4849 component: NewSourcePage,
4950 });
5051
52+const addSourceRoute = createRoute({
53+ getParentRoute: () => rootRoute,
54+ path: "/sources/add",
55+ component: AddSourcePage,
56+});
57+
5158 const sourceRoute = createRoute({
5259 getParentRoute: () => rootRoute,
5360 path: "/sources/$sourceId",
@@ -98,7 +105,7 @@ function EditSourcePageWrapper() { · 44 unmodified lines
98105 // Router
99106 // ---------------------------------------------------------------------------
100107
101−const routeTree = rootRoute.addChildren([homeRoute, newSourceRoute, sourceRoute, editSourceRoute, secretsRoute]);
108+const routeTree = rootRoute.addChildren([homeRoute, newSourceRoute, addSourceRoute, sourceRoute, editSourceRoute, secretsRoute]);
102109
103110 const router = createRouter({
104111 routeTree,
apps/web/src/views/add-source.tsx+1360−0
@@ -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+}
apps/web/src/views/home.tsx+4−3
@@ -1,6 +1,7 @@
11 import { Link } from "@tanstack/react-router";
22 import { useSources, type Source } from "@executor-v3/react";
33 import { LoadableBlock } from "../components/loadable";
4+import { SourceFavicon } from "../components/source-favicon";
45 import { Badge } from "../components/ui/badge";
56 import { Button } from "../components/ui/button";
67 import { IconSources, IconPlus } from "../components/icons";
@@ -21,7 +22,7 @@ export function HomePage() { · 14 unmodified lines
2122 Connected tool providers in this workspace.
2223 </p>
2324 </div>
24− <Link to="/sources/new">
25+ <Link to="/sources/add">
2526 <Button size="sm">
2627 <IconPlus className="size-3.5" />
2728 Add source
@@ -43,7 +44,7 @@ export function HomePage() { · 15 unmodified lines
4344 <p className="text-[13px] text-muted-foreground/60 mb-5">
4445 Add a source to get started.
4546 </p>
46− <Link to="/sources/new">
47+ <Link to="/sources/add">
4748 <Button size="sm">
4849 <IconPlus className="size-3.5" />
4950 Add source
@@ -74,7 +75,7 @@ function SourceCard({ source }: { source: Source }) { · 24 unmodified lines
7475 >
7576 <div className="flex items-start justify-between gap-3 mb-3">
7677 <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" />
7879 </div>
7980 <Badge variant={source.status === "connected" ? "default" : source.status === "error" ? "destructive" : "muted"} className="shrink-0">
8081 {source.status}
apps/web/src/views/source-editor.tsx+212−141
@@ -1,26 +1,28 @@
1−import { useEffect, useState, type ReactNode } from "react";
1+import { useState, type ReactNode } from "react";
22 import { Link, useNavigate } from "@tanstack/react-router";
33 import {
4− type ConnectMcpSourcePayload,
4+ type CompleteSourceOAuthResult,
55 type CreateSourcePayload,
66 type InstanceConfig,
77 type Loadable,
8+ type StartSourceOAuthPayload,
89 type SecretListItem,
910 type Source,
1011 type UpdateSourcePayload,
11− useConnectMcpSource,
1212 useCreateSecret,
1313 useCreateSource,
14− useInvalidateExecutorQueries,
1514 useInstanceConfig,
15+ useRefreshSecrets,
1616 useRemoveSource,
1717 useSecrets,
18+ useStartSourceOAuth,
1819 useSource,
1920 useUpdateSource,
2021 } from "@executor-v3/react";
2122 import { Badge } from "../components/ui/badge";
2223 import { Button } from "../components/ui/button";
2324 import { LoadableBlock } from "../components/loadable";
25+import { SourceFavicon } from "../components/source-favicon";
2426 import {
2527 IconArrowLeft,
2628 IconPencil,
@@ -29,6 +31,8 @@ import { · 2 unmodified lines
2931 IconTrash,
3032 } from "../components/icons";
3133 import { cn } from "../lib/utils";
34+import { sourceTemplates, type SourceTemplate } from "./source-templates";
35+import { getDomain } from "tldts";
3236
3337 type StatusBannerState = {
3438 tone: "info" | "success" | "error";
@@ -37,27 +41,20 @@ type StatusBannerState = { · 2 unmodified lines
3741
3842 type SourceOAuthPopupMessage =
3943 | {
40− type: "executor-v3:source-oauth-result";
44+ type: "executor-v3:oauth-result";
4145 ok: true;
42− sourceId: string;
46+ sessionId: string;
47+ auth: CompleteSourceOAuthResult["auth"];
4348 }
4449 | {
45− type: "executor-v3:source-oauth-result";
50+ type: "executor-v3:oauth-result";
4651 ok: false;
52+ sessionId: string | null;
4753 error: string;
4854 };
4955
5056 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:";
6158
6259 type TransportValue = "" | NonNullable<Source["transport"]>;
6360
@@ -83,62 +80,6 @@ type SourceFormState = { · 19 unmodified lines
8380 oauthRefreshHandle: string;
8481 };
8582
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−
14283 const kindOptions: ReadonlyArray<Source["kind"]> = ["mcp", "openapi", "graphql", "internal"];
14384
14485 const transportOptions: ReadonlyArray<NonNullable<Source["transport"]>> = [
@@ -154,13 +95,56 @@ const trimToNull = (value: string): string | null => { · 9 unmodified lines
15495 return trimmed.length > 0 ? trimmed : null;
15596 };
15697
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"]> => {
158140 if (typeof window === "undefined") {
159141 throw new Error("OAuth popup is only available in a browser context");
160142 }
161143
144+ clearStoredSourceOAuthPopupResult(input.sessionId);
145+
162146 const popup = window.open(
163− authorizationUrl,
147+ input.authorizationUrl,
164148 "executor-v3-source-oauth",
165149 "popup=yes,width=520,height=720",
166150 );
@@ -171,7 +155,7 @@ const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => · 4 unmodified lines
171155
172156 popup.focus();
173157
174− await new Promise<void>((resolve, reject) => {
158+ return await new Promise<CompleteSourceOAuthResult["auth"]>((resolve, reject) => {
175159 let settled = false;
176160 let closedPoll = 0;
177161 let resultTimeout = 0;
@@ -187,6 +171,7 @@ const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => · 9 unmodified lines
187171 if (!popup.closed) {
188172 popup.close();
189173 }
174+ clearStoredSourceOAuthPopupResult(input.sessionId);
190175 };
191176
192177 const settleWithError = (message: string) => {
@@ -199,28 +184,40 @@ const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => · 6 unmodified lines
199184 reject(new Error(message));
200185 };
201186
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+
202202 const onMessage = (event: MessageEvent) => {
203203 if (event.origin !== window.location.origin) {
204204 return;
205205 }
206206
207207 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") {
209209 return;
210210 }
211211
212− if (!data.ok) {
213− settleWithError(data.error || "OAuth failed");
212+ if (data.ok && data.sessionId !== input.sessionId) {
214213 return;
215214 }
216215
217− if (settled) {
216+ if (!data.ok && data.sessionId !== null && data.sessionId !== input.sessionId) {
218217 return;
219218 }
220219
221− settled = true;
222− cleanup();
223− resolve();
220+ settleFromPayload(data);
224221 };
225222
226223 window.addEventListener("message", onMessage);
@@ -230,8 +227,22 @@ const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => · 3 unmodified lines
230227 }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS);
231228
232229 closedPoll = window.setInterval(() => {
230+ const stored = readStoredSourceOAuthPopupResult(input.sessionId);
231+ if (stored) {
232+ settleFromPayload(stored);
233+ return;
234+ }
235+
233236 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);
235246 }
236247 }, 300);
237248 });
@@ -244,12 +255,12 @@ const defaultFormState = (template?: SourceTemplate): SourceFormState => ({ · 6 unmodified lines
244255 name: template?.name ?? "",
245256 kind: template?.kind ?? "openapi",
246257 endpoint: template?.endpoint ?? "",
247− namespace: template?.namespace ?? "",
258+ namespace: template ? namespaceFromUrl(template.endpoint) : "",
248259 enabled: true,
249260 transport: template?.kind === "mcp" ? "auto" : "",
250261 queryParamsText: "",
251262 headersText: "",
252− specUrl: template?.specUrl ?? "",
263+ specUrl: "",
253264 defaultHeadersText: "",
254265 authKind: "none",
255266 authHeaderName: "Authorization",
@@ -375,6 +386,14 @@ const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] = · 119 unmodified lines
375386 };
376387 };
377388
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+
378397 const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => {
379398 const name = state.name.trim();
380399 const endpoint = state.endpoint.trim();
@@ -391,10 +410,11 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 10 unmodified lines
391410 name,
392411 kind: state.kind,
393412 endpoint,
413+ status: buildRequestedSourceStatus(state),
394414 enabled: state.enabled,
395415 namespace: trimToNull(state.namespace),
396416 auth: buildAuthPayload(state),
397− } satisfies Pick<CreateSourcePayload, "name" | "kind" | "endpoint" | "enabled" | "namespace" | "auth">;
417+ } satisfies Pick<CreateSourcePayload, "name" | "kind" | "endpoint" | "status" | "enabled" | "namespace" | "auth">;
398418
399419 if (state.kind === "mcp") {
400420 return {
@@ -423,6 +443,17 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 22 unmodified lines
423443 };
424444 }
425445
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+
426457 return {
427458 ...shared,
428459 transport: null,
@@ -437,10 +468,7 @@ const buildUpdatePayload = (state: SourceFormState): UpdateSourcePayload => ({ · 8 unmodified lines
437468 ...buildSourcePayload(state),
438469 });
439470
440−const buildMcpConnectPayload = (
441− state: SourceFormState,
442− sourceId?: Source["id"],
443−): ConnectMcpSourcePayload => {
471+const buildStartSourceOAuthPayload = (state: SourceFormState): StartSourceOAuthPayload => {
444472 if (state.kind !== "mcp") {
445473 throw new Error("OAuth sign-in is only available for MCP sources.");
446474 }
@@ -451,11 +479,9 @@ const buildMcpConnectPayload = ( · 4 unmodified lines
451479 }
452480
453481 return {
454− ...(sourceId ? { sourceId } : {}),
455− endpoint,
482+ provider: "mcp",
456483 name: trimToNull(state.name),
457− namespace: trimToNull(state.namespace),
458− enabled: state.enabled,
484+ endpoint,
459485 transport: state.transport === "" ? "auto" : state.transport,
460486 queryParams: parseJsonStringMap("Query params", state.queryParamsText),
461487 headers: parseJsonStringMap("Request headers", state.headersText),
@@ -463,7 +489,7 @@ const buildMcpConnectPayload = ( · 1 unmodified line
463489 };
464490
465491 export function NewSourcePage() {
466− return <SourceEditor mode="create" />;
492+ return <SourceEditor key="create" mode="create" />;
467493 }
468494
469495 export function EditSourcePage(props: { sourceId: string }) {
@@ -471,7 +497,13 @@ export function EditSourcePage(props: { sourceId: string }) { · 1 unmodified line
471497
472498 return (
473499 <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+ )}
475507 </LoadableBlock>
476508 );
477509 }
@@ -479,29 +511,29 @@ export function EditSourcePage(props: { sourceId: string }) { · 1 unmodified line
479511 function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) {
480512 const navigate = useNavigate();
481513 const createSource = useCreateSource();
482− const connectMcpSource = useConnectMcpSource();
514+ const startSourceOAuth = useStartSourceOAuth();
483515 const updateSource = useUpdateSource();
484516 const removeSource = useRemoveSource();
485− const invalidateQueries = useInvalidateExecutorQueries();
486517 const instanceConfig = useInstanceConfig();
487518 const secrets = useSecrets();
519+ const refreshSecrets = useRefreshSecrets();
488520 const [formState, setFormState] = useState<SourceFormState>(() =>
489521 props.source ? formStateFromSource(props.source) : defaultFormState(),
490522 );
491523 const [statusBanner, setStatusBanner] = useState<StatusBannerState | null>(null);
492524 const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
493525 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);
501527
502528 const isSubmitting = createSource.status === "pending" || updateSource.status === "pending";
503529 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;
505537
506538 const setField = <K extends keyof SourceFormState>(key: K, value: SourceFormState[K]) => {
507539 setFormState((current) => ({ ...current, [key]: value }));
@@ -513,15 +545,6 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 5 unmodified lines
513545 ...defaultFormState(template),
514546 name: current.name.trim().length > 0 ? current.name : template.name,
515547 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,
525548 }));
526549 setStatusBanner({
527550 tone: "info",
@@ -568,33 +591,34 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 40 unmodified lines
568591 setStatusBanner(null);
569592
570593 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));
584595
585596 setStatusBanner({
586597 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.",
588599 });
589600
590601 setOauthPopupBusy(true);
591− await startSourceOAuthPopup(result.authorizationUrl);
602+ const auth = await startSourceOAuthPopup({
603+ authorizationUrl: result.authorizationUrl,
604+ sessionId: result.sessionId,
605+ });
592606 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.",
598622 });
599623 } catch (error) {
600624 setOauthPopupBusy(false);
@@ -677,8 +701,13 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 76 unmodified lines
677701 : "border-border bg-card/70 hover:bg-accent/50",
678702 )}
679703 >
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>
682711 <Badge variant="outline" className="text-[9px]">{template.kind}</Badge>
683712 </div>
684713 <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
715744 <TextInput
716745 value={formState.endpoint}
717746 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+ }
719754 mono
720755 />
721756 </Field>
@@ -787,6 +822,20 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 65 unmodified lines
787822 </Section>
788823 )}
789824
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+
790839 <Section title="Authentication">
791840 <div className="grid gap-4 sm:grid-cols-2">
792841 {formState.kind === "mcp" && (
@@ -805,9 +854,28 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 12 unmodified lines
805854 disabled={isSubmitting || isDeleting || isOAuthSubmitting}
806855 >
807856 {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"}
809860 </Button>
810861 </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+ )}
811879 </div>
812880 )}
813881 <Field label="Auth mode">
@@ -850,7 +918,10 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 36 unmodified lines
850918 </Field>
851919 )}
852920
853− {formState.authKind === "oauth2" && (
921+ {formState.authKind === "oauth2"
922+ && (formState.kind !== "mcp"
923+ || formState.oauthAccessHandle.trim().length === 0
924+ || showOauthSecretRefs) && (
854925 <>
855926 <Field label="Access token" className="sm:col-span-2">
856927 <SecretPicker
apps/web/src/views/source-templates.ts+89−0
@@ -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+];
bun.lock+11−2
@@ -12,6 +12,7 @@ · 11 unmodified lines
1212 },
1313 "apps/executor": {
1414 "name": "executor",
15+ "version": "0.0.0-local",
1516 "bin": {
1617 "executor": "./bin/executor",
1718 },
@@ -63,6 +64,7 @@ · 45 unmodified lines
6364 "shiki": "^4.0.1",
6465 "streamdown": "^2.3.0",
6566 "tailwind-merge": "^3.5.0",
67+ "tldts": "^7.0.23",
6668 },
6769 "devDependencies": {
6870 "@hono/vite-dev-server": "^0.25.0",
@@ -147,6 +149,7 @@ · 78 unmodified lines
147149 "@executor-v3/runtime-local-inproc": "workspace:*",
148150 "drizzle-orm": "1.0.0-beta.15-859cf75",
149151 "effect": "catalog:",
152+ "graphql": "^16.12.0",
150153 "postgres": "^3.4.7",
151154 "prettier": "^3.8.1",
152155 },
@@ -974,6 +977,8 @@ · 821 unmodified lines
974977
975978 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
976979
980+ "graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="],
981+
977982 "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
978983
979984 "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
@@ -1502,9 +1507,9 @@ · 522 unmodified lines
15021507
15031508 "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
15041509
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=="],
15061511
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=="],
15081513
15091514 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
15101515
@@ -1650,6 +1655,8 @@ · 139 unmodified lines
16501655
16511656 "tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
16521657
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+
16531660 "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=="],
16541661
16551662 "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
16661673
16671674 "@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=="],
16681675
1676+ "tough-cookie/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
1677+
16691678 "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
16701679
16711680 "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],