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
22 "scheduler": "^0.27.0",22 "scheduler": "^0.27.0",
23 "shiki": "^4.0.1",23 "shiki": "^4.0.1",
24 "streamdown": "^2.3.0",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 "devDependencies": {28 "devDependencies": {
28 "@hono/vite-dev-server": "^0.25.0",29 "@hono/vite-dev-server": "^0.25.0",
apps/web/src/components/shell.tsx+6−3
@@ -1,8 +1,9 @@
1import { Link, Outlet, useMatchRoute } from "@tanstack/react-router";1import { Link, Outlet, useMatchRoute } from "@tanstack/react-router";
2import { useSources, type Source } from "@executor-v3/react";2import { useSources, type Source } from "@executor-v3/react";
3import { cn } from "../lib/utils";3import { cn } from "../lib/utils";
4import { IconPlus, IconSources } from "./icons";4import { IconPlus } from "./icons";
5import { LoadableBlock } from "./loadable";5import { LoadableBlock } from "./loadable";
6import { SourceFavicon } from "./source-favicon";
6 7
7// ── Status dot color ─────────────────────────────────────────────────────8// ── Status dot color ─────────────────────────────────────────────────────
8 9
@@ -47,7 +48,7 @@ export function AppShell() { · 38 unmodified lines
47 <div className="flex items-center justify-between gap-2">48 <div className="flex items-center justify-between gap-2">
48 <span>Sources</span>49 <span>Sources</span>
49 <Link50 <Link
50 to="/sources/new"51 to="/sources/add"
51 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 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 <IconPlus className="size-3" />54 <IconPlus className="size-3" />
@@ -113,7 +114,9 @@ function SourceItem(props: { · 59 unmodified lines
113 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",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 <span className="flex-1 truncate">{source.name}</span>120 <span className="flex-1 truncate">{source.name}</span>
118 <span121 <span
119 className={cn("size-1.5 shrink-0 rounded-full", statusColor[source.status] ?? "bg-muted-foreground/30")}122 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 @@
1import { useMemo, useState } from "react";
2import type { Source } from "@executor-v3/react";
3import { getSourceFaviconUrl } from "../lib/source-favicon";
4import { cn } from "../lib/utils";
5
6type SourceKind = Source["kind"] | string;
7
8export 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
42export 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
9import * as Scope from "effect/Scope";9import * as Scope from "effect/Scope";
10import { createLocalExecutorRequestHandler } from "@executor-v3/server";10import { createLocalExecutorRequestHandler } from "@executor-v3/server";
11 11
12const MAX_LOGGED_ERROR_BODY_LENGTH = 4_000;
13
14const 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
19const 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// Create a long-lived scope that stays open for the lifetime of the process.28// Create a long-lived scope that stays open for the lifetime of the process.
13const handlerPromise = Effect.runPromise(29const handlerPromise = Effect.runPromise(
14 Effect.gen(function* () {30 Effect.gen(function* () {
@@ -16,14 +32,46 @@ const handlerPromise = Effect.runPromise( · 1 unmodified line
16 const handler = yield* createLocalExecutorRequestHandler().pipe(32 const handler = yield* createLocalExecutorRequestHandler().pipe(
17 Effect.provideService(Scope.Scope, scope),33 Effect.provideService(Scope.Scope, scope),
18 );34 );
19 handler.setBaseUrl("http://127.0.0.1:8788");
20 return handler;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
24export default {42export default {
25 async fetch(request: Request) {43 async fetch(request: Request) {
26 const handler = await handlerPromise;44 const url = new URL(request.url);
27 return handler.handleApiRequest(request);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};
apps/web/src/lib/source-favicon.ts+63−0
@@ -0,0 +1,63 @@
1import { parse as parseDomain } from "tldts";
2
3const RAW_HOSTS = new Set([
4 "raw.githubusercontent.com",
5 "cdn.jsdelivr.net",
6 "unpkg.com",
7 "raw.github.com",
8]);
9
10const 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
22const 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
47export 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
15import { EditSourcePage, NewSourcePage } from "./views/source-editor";15import { EditSourcePage, NewSourcePage } from "./views/source-editor";
16import { SourceDetailPage } from "./views/source-detail";16import { SourceDetailPage } from "./views/source-detail";
17import { SecretsPage } from "./views/secrets";17import { SecretsPage } from "./views/secrets";
18import { AddSourcePage } from "./views/add-source";
18 19
19// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
20// Route search schema21// Route search schema
@@ -48,6 +49,12 @@ const newSourceRoute = createRoute({ · 27 unmodified lines
48 component: NewSourcePage,49 component: NewSourcePage,
49});50});
50 51
52const addSourceRoute = createRoute({
53 getParentRoute: () => rootRoute,
54 path: "/sources/add",
55 component: AddSourcePage,
56});
57
51const sourceRoute = createRoute({58const sourceRoute = createRoute({
52 getParentRoute: () => rootRoute,59 getParentRoute: () => rootRoute,
53 path: "/sources/$sourceId",60 path: "/sources/$sourceId",
@@ -98,7 +105,7 @@ function EditSourcePageWrapper() { · 44 unmodified lines
98// Router105// Router
99// ---------------------------------------------------------------------------106// ---------------------------------------------------------------------------
100 107
101const routeTree = rootRoute.addChildren([homeRoute, newSourceRoute, sourceRoute, editSourceRoute, secretsRoute]);108const routeTree = rootRoute.addChildren([homeRoute, newSourceRoute, addSourceRoute, sourceRoute, editSourceRoute, secretsRoute]);
102 109
103const router = createRouter({110const router = createRouter({
104 routeTree,111 routeTree,
apps/web/src/views/add-source.tsx+1360−0
@@ -0,0 +1,1360 @@
1import { useState, type ReactNode } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";
3import {
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";
19import { Badge } from "../components/ui/badge";
20import { Button } from "../components/ui/button";
21import { SourceFavicon } from "../components/source-favicon";
22import {
23 IconArrowLeft,
24 IconCheck,
25 IconDiscover,
26 IconPlus,
27 IconSpinner,
28} from "../components/icons";
29import { cn } from "../lib/utils";
30import { sourceTemplates, type SourceTemplate } from "./source-templates";
31import { getDomain } from "tldts";
32
33// ---------------------------------------------------------------------------
34// State machine
35// ---------------------------------------------------------------------------
36
37type FlowPhase =
38 | "idle"
39 | "discovering"
40 | "editing"
41 | "connecting"
42 | "connected"
43 | "credential_required"
44 | "oauth_required";
45
46type ProbeAuthKind = "none" | "bearer" | "basic" | "headers";
47
48type ProbeAuthState = {
49 kind: ProbeAuthKind;
50 token: string;
51 headerName: string;
52 prefix: string;
53 username: string;
54 password: string;
55 headersText: string;
56};
57
58type 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
75type OAuthRequiredInfo = {
76 source: Source;
77 sessionId: string;
78 authorizationUrl: string;
79};
80
81// ---------------------------------------------------------------------------
82// Constants
83// ---------------------------------------------------------------------------
84
85const kindOptions: ReadonlyArray<ConnectFormState["kind"]> = ["mcp", "openapi", "graphql"];
86
87const transportOptions: ReadonlyArray<NonNullable<Source["transport"]>> = [
88 "auto",
89 "streamable-http",
90 "sse",
91];
92
93const authOptions: ReadonlyArray<ConnectFormState["authKind"]> = ["none", "bearer", "oauth2"];
94
95const probeAuthOptions: ReadonlyArray<ProbeAuthKind> = ["none", "bearer", "basic", "headers"];
96
97const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;
98const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor-v3:oauth-result:";
99
100// ---------------------------------------------------------------------------
101// Helpers
102// ---------------------------------------------------------------------------
103
104const trimToNull = (value: string): string | null => {
105 const trimmed = value.trim();
106 return trimmed.length > 0 ? trimmed : null;
107};
108
109const 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
121const 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
154const 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
217const 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
226const 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
251const 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
292const 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
335type 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
349const 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
362const clearStoredSourceOAuthPopupResult = (sessionId: string): void => {
363 if (typeof window === "undefined") return;
364 window.localStorage.removeItem(`${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`);
365};
366
367const 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
465export 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
1100function 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
1111function 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
1124function 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
1143function 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
1163function 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
1179function 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
1201const CREATE_NEW_VALUE = "__create_new__";
1202const INLINE_TOKEN_VALUE = "__inline_token__";
1203
1204function 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 @@
1import { Link } from "@tanstack/react-router";1import { Link } from "@tanstack/react-router";
2import { useSources, type Source } from "@executor-v3/react";2import { useSources, type Source } from "@executor-v3/react";
3import { LoadableBlock } from "../components/loadable";3import { LoadableBlock } from "../components/loadable";
4import { SourceFavicon } from "../components/source-favicon";
4import { Badge } from "../components/ui/badge";5import { Badge } from "../components/ui/badge";
5import { Button } from "../components/ui/button";6import { Button } from "../components/ui/button";
6import { IconSources, IconPlus } from "../components/icons";7import { IconSources, IconPlus } from "../components/icons";
@@ -21,7 +22,7 @@ export function HomePage() { · 14 unmodified lines
21 Connected tool providers in this workspace.22 Connected tool providers in this workspace.
22 </p>23 </p>
23 </div>24 </div>
24 <Link to="/sources/new">25 <Link to="/sources/add">
25 <Button size="sm">26 <Button size="sm">
26 <IconPlus className="size-3.5" />27 <IconPlus className="size-3.5" />
27 Add source28 Add source
@@ -43,7 +44,7 @@ export function HomePage() { · 15 unmodified lines
43 <p className="text-[13px] text-muted-foreground/60 mb-5">44 <p className="text-[13px] text-muted-foreground/60 mb-5">
44 Add a source to get started.45 Add a source to get started.
45 </p>46 </p>
46 <Link to="/sources/new">47 <Link to="/sources/add">
47 <Button size="sm">48 <Button size="sm">
48 <IconPlus className="size-3.5" />49 <IconPlus className="size-3.5" />
49 Add source50 Add source
@@ -74,7 +75,7 @@ function SourceCard({ source }: { source: Source }) { · 24 unmodified lines
74 >75 >
75 <div className="flex items-start justify-between gap-3 mb-3">76 <div className="flex items-start justify-between gap-3 mb-3">
76 <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary">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 </div>79 </div>
79 <Badge variant={source.status === "connected" ? "default" : source.status === "error" ? "destructive" : "muted"} className="shrink-0">80 <Badge variant={source.status === "connected" ? "default" : source.status === "error" ? "destructive" : "muted"} className="shrink-0">
80 {source.status}81 {source.status}
apps/web/src/views/source-editor.tsx+212−141
@@ -1,26 +1,28 @@
1import { useEffect, useState, type ReactNode } from "react";1import { useState, type ReactNode } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import {3import {
4 type ConnectMcpSourcePayload,4 type CompleteSourceOAuthResult,
5 type CreateSourcePayload,5 type CreateSourcePayload,
6 type InstanceConfig,6 type InstanceConfig,
7 type Loadable,7 type Loadable,
8 type StartSourceOAuthPayload,
8 type SecretListItem,9 type SecretListItem,
9 type Source,10 type Source,
10 type UpdateSourcePayload,11 type UpdateSourcePayload,
11 useConnectMcpSource,
12 useCreateSecret,12 useCreateSecret,
13 useCreateSource,13 useCreateSource,
14 useInvalidateExecutorQueries,
15 useInstanceConfig,14 useInstanceConfig,
15 useRefreshSecrets,
16 useRemoveSource,16 useRemoveSource,
17 useSecrets,17 useSecrets,
18 useStartSourceOAuth,
18 useSource,19 useSource,
19 useUpdateSource,20 useUpdateSource,
20} from "@executor-v3/react";21} from "@executor-v3/react";
21import { Badge } from "../components/ui/badge";22import { Badge } from "../components/ui/badge";
22import { Button } from "../components/ui/button";23import { Button } from "../components/ui/button";
23import { LoadableBlock } from "../components/loadable";24import { LoadableBlock } from "../components/loadable";
25import { SourceFavicon } from "../components/source-favicon";
24import {26import {
25 IconArrowLeft,27 IconArrowLeft,
26 IconPencil,28 IconPencil,
@@ -29,6 +31,8 @@ import { · 2 unmodified lines
29 IconTrash,31 IconTrash,
30} from "../components/icons";32} from "../components/icons";
31import { cn } from "../lib/utils";33import { cn } from "../lib/utils";
34import { sourceTemplates, type SourceTemplate } from "./source-templates";
35import { getDomain } from "tldts";
32 36
33type StatusBannerState = {37type StatusBannerState = {
34 tone: "info" | "success" | "error";38 tone: "info" | "success" | "error";
@@ -37,27 +41,20 @@ type StatusBannerState = { · 2 unmodified lines
37 41
38type SourceOAuthPopupMessage =42type SourceOAuthPopupMessage =
39 | {43 | {
40 type: "executor-v3:source-oauth-result";44 type: "executor-v3:oauth-result";
41 ok: true;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 ok: false;51 ok: false;
52 sessionId: string | null;
47 error: string;53 error: string;
48 };54 };
49 55
50const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;56const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;
51 57const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor-v3:oauth-result:";
52type SourceTemplate = {
53 id: string;
54 name: string;
55 summary: string;
56 kind: Source["kind"];
57 endpoint: string;
58 specUrl?: string;
59 namespace?: string;
60};
61 58
62type TransportValue = "" | NonNullable<Source["transport"]>;59type TransportValue = "" | NonNullable<Source["transport"]>;
63 60
@@ -83,62 +80,6 @@ type SourceFormState = { · 19 unmodified lines
83 oauthRefreshHandle: string;80 oauthRefreshHandle: string;
84};81};
85 82
86const 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
142const kindOptions: ReadonlyArray<Source["kind"]> = ["mcp", "openapi", "graphql", "internal"];83const kindOptions: ReadonlyArray<Source["kind"]> = ["mcp", "openapi", "graphql", "internal"];
143 84
144const transportOptions: ReadonlyArray<NonNullable<Source["transport"]>> = [85const transportOptions: ReadonlyArray<NonNullable<Source["transport"]>> = [
@@ -154,13 +95,56 @@ const trimToNull = (value: string): string | null => { · 9 unmodified lines
154 return trimmed.length > 0 ? trimmed : null;95 return trimmed.length > 0 ? trimmed : null;
155};96};
156 97
157const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => {98const 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
109const 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
128const 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
136const startSourceOAuthPopup = async (input: {
137 authorizationUrl: string;
138 sessionId: string;
139}): Promise<CompleteSourceOAuthResult["auth"]> => {
158 if (typeof window === "undefined") {140 if (typeof window === "undefined") {
159 throw new Error("OAuth popup is only available in a browser context");141 throw new Error("OAuth popup is only available in a browser context");
160 }142 }
161 143
144 clearStoredSourceOAuthPopupResult(input.sessionId);
145
162 const popup = window.open(146 const popup = window.open(
163 authorizationUrl,147 input.authorizationUrl,
164 "executor-v3-source-oauth",148 "executor-v3-source-oauth",
165 "popup=yes,width=520,height=720",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 popup.focus();156 popup.focus();
173 157
174 await new Promise<void>((resolve, reject) => {158 return await new Promise<CompleteSourceOAuthResult["auth"]>((resolve, reject) => {
175 let settled = false;159 let settled = false;
176 let closedPoll = 0;160 let closedPoll = 0;
177 let resultTimeout = 0;161 let resultTimeout = 0;
@@ -187,6 +171,7 @@ const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => · 9 unmodified lines
187 if (!popup.closed) {171 if (!popup.closed) {
188 popup.close();172 popup.close();
189 }173 }
174 clearStoredSourceOAuthPopupResult(input.sessionId);
190 };175 };
191 176
192 const settleWithError = (message: string) => {177 const settleWithError = (message: string) => {
@@ -199,28 +184,40 @@ const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => · 6 unmodified lines
199 reject(new Error(message));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 const onMessage = (event: MessageEvent) => {202 const onMessage = (event: MessageEvent) => {
203 if (event.origin !== window.location.origin) {203 if (event.origin !== window.location.origin) {
204 return;204 return;
205 }205 }
206 206
207 const data = event.data as SourceOAuthPopupMessage | undefined;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 return;209 return;
210 }210 }
211 211
212 if (!data.ok) {212 if (data.ok && data.sessionId !== input.sessionId) {
213 settleWithError(data.error || "OAuth failed");
214 return;213 return;
215 }214 }
216 215
217 if (settled) {216 if (!data.ok && data.sessionId !== null && data.sessionId !== input.sessionId) {
218 return;217 return;
219 }218 }
220 219
221 settled = true;220 settleFromPayload(data);
222 cleanup();
223 resolve();
224 };221 };
225 222
226 window.addEventListener("message", onMessage);223 window.addEventListener("message", onMessage);
@@ -230,8 +227,22 @@ const startSourceOAuthPopup = async (authorizationUrl: string): Promise<void> => · 3 unmodified lines
230 }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS);227 }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS);
231 228
232 closedPoll = window.setInterval(() => {229 closedPoll = window.setInterval(() => {
230 const stored = readStoredSourceOAuthPopupResult(input.sessionId);
231 if (stored) {
232 settleFromPayload(stored);
233 return;
234 }
235
233 if (popup.closed) {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 }, 300);247 }, 300);
237 });248 });
@@ -244,12 +255,12 @@ const defaultFormState = (template?: SourceTemplate): SourceFormState => ({ · 6 unmodified lines
244 name: template?.name ?? "",255 name: template?.name ?? "",
245 kind: template?.kind ?? "openapi",256 kind: template?.kind ?? "openapi",
246 endpoint: template?.endpoint ?? "",257 endpoint: template?.endpoint ?? "",
247 namespace: template?.namespace ?? "",258 namespace: template ? namespaceFromUrl(template.endpoint) : "",
248 enabled: true,259 enabled: true,
249 transport: template?.kind === "mcp" ? "auto" : "",260 transport: template?.kind === "mcp" ? "auto" : "",
250 queryParamsText: "",261 queryParamsText: "",
251 headersText: "",262 headersText: "",
252 specUrl: template?.specUrl ?? "",263 specUrl: "",
253 defaultHeadersText: "",264 defaultHeadersText: "",
254 authKind: "none",265 authKind: "none",
255 authHeaderName: "Authorization",266 authHeaderName: "Authorization",
@@ -375,6 +386,14 @@ const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] = · 119 unmodified lines
375 };386 };
376};387};
377 388
389const 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
378const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => {397const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => {
379 const name = state.name.trim();398 const name = state.name.trim();
380 const endpoint = state.endpoint.trim();399 const endpoint = state.endpoint.trim();
@@ -391,10 +410,11 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 10 unmodified lines
391 name,410 name,
392 kind: state.kind,411 kind: state.kind,
393 endpoint,412 endpoint,
413 status: buildRequestedSourceStatus(state),
394 enabled: state.enabled,414 enabled: state.enabled,
395 namespace: trimToNull(state.namespace),415 namespace: trimToNull(state.namespace),
396 auth: buildAuthPayload(state),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 if (state.kind === "mcp") {419 if (state.kind === "mcp") {
400 return {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 return {457 return {
427 ...shared,458 ...shared,
428 transport: null,459 transport: null,
@@ -437,10 +468,7 @@ const buildUpdatePayload = (state: SourceFormState): UpdateSourcePayload => ({ · 8 unmodified lines
437 ...buildSourcePayload(state),468 ...buildSourcePayload(state),
438});469});
439 470
440const buildMcpConnectPayload = (471const buildStartSourceOAuthPayload = (state: SourceFormState): StartSourceOAuthPayload => {
441 state: SourceFormState,
442 sourceId?: Source["id"],
443): ConnectMcpSourcePayload => {
444 if (state.kind !== "mcp") {472 if (state.kind !== "mcp") {
445 throw new Error("OAuth sign-in is only available for MCP sources.");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 return {481 return {
454 ...(sourceId ? { sourceId } : {}),482 provider: "mcp",
455 endpoint,
456 name: trimToNull(state.name),483 name: trimToNull(state.name),
457 namespace: trimToNull(state.namespace),484 endpoint,
458 enabled: state.enabled,
459 transport: state.transport === "" ? "auto" : state.transport,485 transport: state.transport === "" ? "auto" : state.transport,
460 queryParams: parseJsonStringMap("Query params", state.queryParamsText),486 queryParams: parseJsonStringMap("Query params", state.queryParamsText),
461 headers: parseJsonStringMap("Request headers", state.headersText),487 headers: parseJsonStringMap("Request headers", state.headersText),
@@ -463,7 +489,7 @@ const buildMcpConnectPayload = ( · 1 unmodified line
463};489};
464 490
465export function NewSourcePage() {491export function NewSourcePage() {
466 return <SourceEditor mode="create" />;492 return <SourceEditor key="create" mode="create" />;
467}493}
468 494
469export function EditSourcePage(props: { sourceId: string }) {495export function EditSourcePage(props: { sourceId: string }) {
@@ -471,7 +497,13 @@ export function EditSourcePage(props: { sourceId: string }) { · 1 unmodified line
471 497
472 return (498 return (
473 <LoadableBlock loadable={source} loading="Loading source...">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 </LoadableBlock>507 </LoadableBlock>
476 );508 );
477}509}
@@ -479,29 +511,29 @@ export function EditSourcePage(props: { sourceId: string }) { · 1 unmodified line
479function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) {511function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) {
480 const navigate = useNavigate();512 const navigate = useNavigate();
481 const createSource = useCreateSource();513 const createSource = useCreateSource();
482 const connectMcpSource = useConnectMcpSource();514 const startSourceOAuth = useStartSourceOAuth();
483 const updateSource = useUpdateSource();515 const updateSource = useUpdateSource();
484 const removeSource = useRemoveSource();516 const removeSource = useRemoveSource();
485 const invalidateQueries = useInvalidateExecutorQueries();
486 const instanceConfig = useInstanceConfig();517 const instanceConfig = useInstanceConfig();
487 const secrets = useSecrets();518 const secrets = useSecrets();
519 const refreshSecrets = useRefreshSecrets();
488 const [formState, setFormState] = useState<SourceFormState>(() =>520 const [formState, setFormState] = useState<SourceFormState>(() =>
489 props.source ? formStateFromSource(props.source) : defaultFormState(),521 props.source ? formStateFromSource(props.source) : defaultFormState(),
490 );522 );
491 const [statusBanner, setStatusBanner] = useState<StatusBannerState | null>(null);523 const [statusBanner, setStatusBanner] = useState<StatusBannerState | null>(null);
492 const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);524 const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
493 const [oauthPopupBusy, setOauthPopupBusy] = useState(false);525 const [oauthPopupBusy, setOauthPopupBusy] = useState(false);
494 526 const [expandedOauthSecretRefTarget, setExpandedOauthSecretRefTarget] = useState<string | null>(null);
495 useEffect(() => {
496 if (props.source) {
497 setFormState(formStateFromSource(props.source));
498 setStatusBanner(null);
499 }
500 }, [props.source]);
501 527
502 const isSubmitting = createSource.status === "pending" || updateSource.status === "pending";528 const isSubmitting = createSource.status === "pending" || updateSource.status === "pending";
503 const isDeleting = removeSource.status === "pending";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 const setField = <K extends keyof SourceFormState>(key: K, value: SourceFormState[K]) => {538 const setField = <K extends keyof SourceFormState>(key: K, value: SourceFormState[K]) => {
507 setFormState((current) => ({ ...current, [key]: value }));539 setFormState((current) => ({ ...current, [key]: value }));
@@ -513,15 +545,6 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 5 unmodified lines
513 ...defaultFormState(template),545 ...defaultFormState(template),
514 name: current.name.trim().length > 0 ? current.name : template.name,546 name: current.name.trim().length > 0 ? current.name : template.name,
515 enabled: current.enabled,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 setStatusBanner({549 setStatusBanner({
527 tone: "info",550 tone: "info",
@@ -568,33 +591,34 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 40 unmodified lines
568 setStatusBanner(null);591 setStatusBanner(null);
569 592
570 try {593 try {
571 const result = await connectMcpSource.mutateAsync(594 const result = await startSourceOAuth.mutateAsync(buildStartSourceOAuthPayload(formState));
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 }
584 595
585 setStatusBanner({596 setStatusBanner({
586 tone: "info",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 setOauthPopupBusy(true);601 setOauthPopupBusy(true);
591 await startSourceOAuthPopup(result.authorizationUrl);602 const auth = await startSourceOAuthPopup({
603 authorizationUrl: result.authorizationUrl,
604 sessionId: result.sessionId,
605 });
592 setOauthPopupBusy(false);606 setOauthPopupBusy(false);
593 invalidateQueries();607 refreshSecrets();
594 void navigate({608 setExpandedOauthSecretRefTarget(null);
595 to: "/sources/$sourceId",609 setFormState((current) => ({
596 params: { sourceId: result.source.id },610 ...current,
597 search: { tab: "model" },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 } catch (error) {623 } catch (error) {
600 setOauthPopupBusy(false);624 setOauthPopupBusy(false);
@@ -677,8 +701,13 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 76 unmodified lines
677 : "border-border bg-card/70 hover:bg-accent/50",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">704 <div className="mb-1 flex items-center justify-between gap-2">
681 <span className="text-[13px] font-medium text-foreground">{template.name}</span>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 <Badge variant="outline" className="text-[9px]">{template.kind}</Badge>711 <Badge variant="outline" className="text-[9px]">{template.kind}</Badge>
683 </div>712 </div>
684 <span className="text-[11px] text-muted-foreground line-clamp-1">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 <TextInput744 <TextInput
716 value={formState.endpoint}745 value={formState.endpoint}
717 onChange={(value) => setField("endpoint", value)}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 mono754 mono
720 />755 />
721 </Field>756 </Field>
@@ -787,6 +822,20 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 65 unmodified lines
787 </Section>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 <Section title="Authentication">839 <Section title="Authentication">
791 <div className="grid gap-4 sm:grid-cols-2">840 <div className="grid gap-4 sm:grid-cols-2">
792 {formState.kind === "mcp" && (841 {formState.kind === "mcp" && (
@@ -805,9 +854,28 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 12 unmodified lines
805 disabled={isSubmitting || isDeleting || isOAuthSubmitting}854 disabled={isSubmitting || isDeleting || isOAuthSubmitting}
806 >855 >
807 {isOAuthSubmitting ? <IconSpinner className="size-3.5" /> : null}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 </Button>860 </Button>
810 </div>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 </div>879 </div>
812 )}880 )}
813 <Field label="Auth mode">881 <Field label="Auth mode">
@@ -850,7 +918,10 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 36 unmodified lines
850 </Field>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 <Field label="Access token" className="sm:col-span-2">926 <Field label="Access token" className="sm:col-span-2">
856 <SecretPicker927 <SecretPicker
apps/web/src/views/source-templates.ts+89−0
@@ -0,0 +1,89 @@
1import type { Source } from "@executor-v3/react";
2
3export type SourceTemplate = {
4 id: string;
5 name: string;
6 summary: string;
7 kind: Source["kind"];
8 endpoint: string;
9};
10
11export 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
12 },12 },
13 "apps/executor": {13 "apps/executor": {
14 "name": "executor",14 "name": "executor",
15 "version": "0.0.0-local",
15 "bin": {16 "bin": {
16 "executor": "./bin/executor",17 "executor": "./bin/executor",
17 },18 },
@@ -63,6 +64,7 @@ · 45 unmodified lines
63 "shiki": "^4.0.1",64 "shiki": "^4.0.1",
64 "streamdown": "^2.3.0",65 "streamdown": "^2.3.0",
65 "tailwind-merge": "^3.5.0",66 "tailwind-merge": "^3.5.0",
67 "tldts": "^7.0.23",
66 },68 },
67 "devDependencies": {69 "devDependencies": {
68 "@hono/vite-dev-server": "^0.25.0",70 "@hono/vite-dev-server": "^0.25.0",
@@ -147,6 +149,7 @@ · 78 unmodified lines
147 "@executor-v3/runtime-local-inproc": "workspace:*",149 "@executor-v3/runtime-local-inproc": "workspace:*",
148 "drizzle-orm": "1.0.0-beta.15-859cf75",150 "drizzle-orm": "1.0.0-beta.15-859cf75",
149 "effect": "catalog:",151 "effect": "catalog:",
152 "graphql": "^16.12.0",
150 "postgres": "^3.4.7",153 "postgres": "^3.4.7",
151 "prettier": "^3.8.1",154 "prettier": "^3.8.1",
152 },155 },
@@ -974,6 +977,8 @@ · 821 unmodified lines
974 977
975 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],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 "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],982 "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
978 983
979 "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],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 "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],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 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],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 "tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],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 "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=="],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 "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],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 "@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=="],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 "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],1678 "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
1670 1679
1671 "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],1680 "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],