Unified · Split
| @@ -1,14 +1,29 @@ | |||
| 1 | { | 1 | { |
| 2 | "sources": { | 2 | "sources": { |
| 3 | "vercel-api": { | 3 | "axiom-mcp": { |
| 4 | "name": "Vercel API", | 4 | "name": "Axiom MCP", |
| 5 | "kind": "openapi" | 5 | "namespace": "axiom.mcp", |
| 6 | }, | ||
| 7 | "axiom": { | ||
| 8 | "kind": "mcp" | 6 | "kind": "mcp" |
| 9 | }, | 7 | }, |
| 10 | "linear": { | 8 | "google-calendar": { |
| 11 | "kind": "graphql" | 9 | "name": "Google Calendar", |
| 10 | "namespace": "google.calendar", | ||
| 11 | "kind": "google_discovery" | ||
| 12 | }, | ||
| 13 | "google-drive": { | ||
| 14 | "name": "Google Drive", | ||
| 15 | "namespace": "google.drive", | ||
| 16 | "kind": "google_discovery" | ||
| 17 | }, | ||
| 18 | "google-gmail": { | ||
| 19 | "name": "Gmail", | ||
| 20 | "namespace": "google.gmail", | ||
| 21 | "kind": "google_discovery" | ||
| 22 | }, | ||
| 23 | "chrome-devtools-mcp": { | ||
| 24 | "kind": "mcp", | ||
| 25 | "name": "Chrome DevTools MCP", | ||
| 26 | "namespace": "chrome.devtools.mcp" | ||
| 12 | } | 27 | } |
| 13 | } | 28 | } |
| 14 | } | 29 | } |
| @@ -0,0 +1,13 @@ | |||
| 1 | { | ||
| 2 | "mcpServers": { | ||
| 3 | "executor-stdio": { | ||
| 4 | "command": "bun", | ||
| 5 | "args": [ | ||
| 6 | "run", | ||
| 7 | "executor", | ||
| 8 | "mcp", | ||
| 9 | "--stdio" | ||
| 10 | ] | ||
| 11 | } | ||
| 12 | } | ||
| 13 | } | ||
| @@ -57,7 +57,6 @@ import { · 56 unmodified lines | |||
| 57 | createLocalExecutorServer, | 57 | createLocalExecutorServer, |
| 58 | DEFAULT_SERVER_BASE_URL, | 58 | DEFAULT_SERVER_BASE_URL, |
| 59 | DEFAULT_SERVER_HOST, | 59 | DEFAULT_SERVER_HOST, |
| 60 | DEFAULT_LOCAL_DATA_DIR, | ||
| 61 | DEFAULT_SERVER_LOG_FILE, | 60 | DEFAULT_SERVER_LOG_FILE, |
| 62 | DEFAULT_SERVER_PID_FILE, | 61 | DEFAULT_SERVER_PID_FILE, |
| 63 | DEFAULT_SERVER_PORT, | 62 | DEFAULT_SERVER_PORT, |
| @@ -66,6 +65,7 @@ import { · 2 unmodified lines | |||
| 66 | runLocalExecutorServer, | 65 | runLocalExecutorServer, |
| 67 | } from "@executor/server"; | 66 | } from "@executor/server"; |
| 68 | import { | 67 | import { |
| 68 | resolveCliLocalDataDir, | ||
| 69 | resolveRuntimeWebAssetsDir, | 69 | resolveRuntimeWebAssetsDir, |
| 70 | resolveSelfCommand, | 70 | resolveSelfCommand, |
| 71 | } from "./runtime-paths"; | 71 | } from "./runtime-paths"; |
| @@ -178,6 +178,7 @@ const getBootstrapClient = (baseUrl: string = DEFAULT_SERVER_BASE_URL) => · 106 unmodified lines | |||
| 178 | const decodeExecutionId = Schema.decodeUnknown(ExecutionIdSchema); | 178 | const decodeExecutionId = Schema.decodeUnknown(ExecutionIdSchema); |
| 179 | const require = createRequire(import.meta.url); | 179 | const require = createRequire(import.meta.url); |
| 180 | const CLI_NAME = "executor"; | 180 | const CLI_NAME = "executor"; |
| 181 | const CLI_LOCAL_DATA_DIR = resolveCliLocalDataDir(); | ||
| 181 | const CLI_VERSION = (() => { | 182 | const CLI_VERSION = (() => { |
| 182 | const candidatePaths = [ | 183 | const candidatePaths = [ |
| 183 | "../package.json", | 184 | "../package.json", |
| @@ -299,33 +300,33 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 115 unmodified lines | |||
| 299 | Effect.acquireUseRelease( | 300 | Effect.acquireUseRelease( |
| 300 | createExecutorEffect({ | 301 | createExecutorEffect({ |
| 301 | backend: createLocalExecutorBackend({ | 302 | backend: createLocalExecutorBackend({ |
| 302 | localDataDir: DEFAULT_LOCAL_DATA_DIR, | 303 | localDataDir: CLI_LOCAL_DATA_DIR, |
| 303 | }), | 304 | }), |
| 304 | plugins: [ | 305 | plugins: [ |
| 305 | graphqlSdkPlugin({ | 306 | graphqlSdkPlugin({ |
| 306 | storage: createFileGraphqlSourceStorage({ | 307 | storage: createFileGraphqlSourceStorage({ |
| 307 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/graphql/sources`, | 308 | rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/graphql/sources`, |
| 308 | }), | 309 | }), |
| 309 | }), | 310 | }), |
| 310 | googleDiscoverySdkPlugin({ | 311 | googleDiscoverySdkPlugin({ |
| 311 | storage: createFileGoogleDiscoverySourceStorage({ | 312 | storage: createFileGoogleDiscoverySourceStorage({ |
| 312 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/google-discovery/sources`, | 313 | rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/google-discovery/sources`, |
| 313 | }), | 314 | }), |
| 314 | oauthSessions: createFileGoogleDiscoveryOAuthSessionStorage({ | 315 | oauthSessions: createFileGoogleDiscoveryOAuthSessionStorage({ |
| 315 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/google-discovery/oauth-sessions`, | 316 | rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/google-discovery/oauth-sessions`, |
| 316 | }), | 317 | }), |
| 317 | }), | 318 | }), |
| 318 | mcpSdkPlugin({ | 319 | mcpSdkPlugin({ |
| 319 | storage: createFileMcpSourceStorage({ | 320 | storage: createFileMcpSourceStorage({ |
| 320 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/mcp/sources`, | 321 | rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/mcp/sources`, |
| 321 | }), | 322 | }), |
| 322 | oauthSessions: createFileMcpOAuthSessionStorage({ | 323 | oauthSessions: createFileMcpOAuthSessionStorage({ |
| 323 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/mcp/oauth-sessions`, | 324 | rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/mcp/oauth-sessions`, |
| 324 | }), | 325 | }), |
| 325 | }), | 326 | }), |
| 326 | openApiSdkPlugin({ | 327 | openApiSdkPlugin({ |
| 327 | storage: createFileOpenApiSourceStorage({ | 328 | storage: createFileOpenApiSourceStorage({ |
| 328 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/openapi/sources`, | 329 | rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/openapi/sources`, |
| 329 | }), | 330 | }), |
| 330 | }), | 331 | }), |
| 331 | ] as const, | 332 | ] as const, |
| @@ -476,7 +477,7 @@ const getDefaultServerOptions = (port: number = DEFAULT_SERVER_PORT) => { · 144 unmodified lines | |||
| 476 | return { | 477 | return { |
| 477 | host: DEFAULT_SERVER_HOST, | 478 | host: DEFAULT_SERVER_HOST, |
| 478 | port, | 479 | port, |
| 479 | localDataDir: DEFAULT_LOCAL_DATA_DIR, | 480 | localDataDir: CLI_LOCAL_DATA_DIR, |
| 480 | pidFile: DEFAULT_SERVER_PID_FILE, | 481 | pidFile: DEFAULT_SERVER_PID_FILE, |
| 481 | ui: assetsDir ? { assetsDir } : undefined, | 482 | ui: assetsDir ? { assetsDir } : undefined, |
| 482 | }; | 483 | }; |
| @@ -638,7 +639,7 @@ const getServerStatus = ( · 155 unmodified lines | |||
| 638 | pid, | 639 | pid, |
| 639 | pidRunning, | 640 | pidRunning, |
| 640 | logFile, | 641 | logFile, |
| 641 | localDataDir: DEFAULT_LOCAL_DATA_DIR, | 642 | localDataDir: CLI_LOCAL_DATA_DIR, |
| 642 | webAssetsDir: resolveRuntimeWebAssetsDir(), | 643 | webAssetsDir: resolveRuntimeWebAssetsDir(), |
| 643 | installation, | 644 | installation, |
| 644 | } satisfies LocalServerStatus; | 645 | } satisfies LocalServerStatus; |
| @@ -0,0 +1,47 @@ | |||
| 1 | import { join } from "node:path"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | DEFAULT_EXECUTOR_DATA_DIR, | ||
| 6 | DEFAULT_LOCAL_DATA_DIR, | ||
| 7 | } from "@executor/server"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | resolveCliLocalDataDir, | ||
| 11 | } from "./runtime-paths"; | ||
| 12 | |||
| 13 | describe("runtime-paths", () => { | ||
| 14 | it("uses the web dev local data dir for source-launched CLI runs", () => { | ||
| 15 | const originalArgv1 = process.argv[1]; | ||
| 16 | |||
| 17 | try { | ||
| 18 | process.argv[1] = "/tmp/executor.ts"; | ||
| 19 | |||
| 20 | expect(resolveCliLocalDataDir()).toBe( | ||
| 21 | join(DEFAULT_EXECUTOR_DATA_DIR, "control-plane-web-dev"), | ||
| 22 | ); | ||
| 23 | } finally { | ||
| 24 | if (originalArgv1 === undefined) { | ||
| 25 | delete process.argv[1]; | ||
| 26 | } else { | ||
| 27 | process.argv[1] = originalArgv1; | ||
| 28 | } | ||
| 29 | } | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("keeps the default local data dir for bundled launches", () => { | ||
| 33 | const originalArgv1 = process.argv[1]; | ||
| 34 | |||
| 35 | try { | ||
| 36 | process.argv[1] = "/tmp/executor"; | ||
| 37 | |||
| 38 | expect(resolveCliLocalDataDir()).toBe(DEFAULT_LOCAL_DATA_DIR); | ||
| 39 | } finally { | ||
| 40 | if (originalArgv1 === undefined) { | ||
| 41 | delete process.argv[1]; | ||
| 42 | } else { | ||
| 43 | process.argv[1] = originalArgv1; | ||
| 44 | } | ||
| 45 | } | ||
| 46 | }); | ||
| 47 | }); | ||
| @@ -1,8 +1,11 @@ | |||
| 1 | import { existsSync } from "node:fs"; | 1 | import { existsSync } from "node:fs"; |
| 2 | import { dirname, extname, resolve } from "node:path"; | 2 | import { dirname, extname, join, resolve } from "node:path"; |
| 3 | import { fileURLToPath } from "node:url"; | 3 | import { fileURLToPath } from "node:url"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | DEFAULT_EXECUTOR_DATA_DIR, | ||
| 7 | DEFAULT_LOCAL_DATA_DIR, | ||
| 8 | EXECUTOR_LOCAL_DATA_DIR_ENV, | ||
| 6 | EXECUTOR_WEB_ASSETS_DIR_ENV, | 9 | EXECUTOR_WEB_ASSETS_DIR_ENV, |
| 7 | } from "@executor/server"; | 10 | } from "@executor/server"; |
| 8 | 11 | ||
| @@ -36,6 +39,8 @@ const getSourceEntrypoint = (): string | null => { · 27 unmodified lines | |||
| 36 | : null; | 39 | : null; |
| 37 | }; | 40 | }; |
| 38 | 41 | ||
| 42 | const isSourceCliLaunch = (): boolean => getSourceEntrypoint() !== null; | ||
| 43 | |||
| 39 | const resolveBundledNodeLauncher = (): string | null => { | 44 | const resolveBundledNodeLauncher = (): string | null => { |
| 40 | const candidate = resolve(sourceDir, "executor.js"); | 45 | const candidate = resolve(sourceDir, "executor.js"); |
| 41 | return existsSync(candidate) ? candidate : null; | 46 | return existsSync(candidate) ? candidate : null; |
| @@ -76,6 +81,16 @@ export const resolveSelfCommand = (args: readonly string[]): readonly string[] = · 34 unmodified lines | |||
| 76 | : [process.execPath, sourceEntrypoint, ...args]; | 81 | : [process.execPath, sourceEntrypoint, ...args]; |
| 77 | }; | 82 | }; |
| 78 | 83 | ||
| 84 | export const resolveCliLocalDataDir = (): string => { | ||
| 85 | if (trim(process.env[EXECUTOR_LOCAL_DATA_DIR_ENV])) { | ||
| 86 | return DEFAULT_LOCAL_DATA_DIR; | ||
| 87 | } | ||
| 88 | |||
| 89 | return isSourceCliLaunch() | ||
| 90 | ? join(DEFAULT_EXECUTOR_DATA_DIR, "control-plane-web-dev") | ||
| 91 | : DEFAULT_LOCAL_DATA_DIR; | ||
| 92 | }; | ||
| 93 | |||
| 79 | export const resolveRuntimeWebAssetsDir = (): string | null => { | 94 | export const resolveRuntimeWebAssetsDir = (): string | null => { |
| 80 | const explicit = resolveIfExists(process.env[EXECUTOR_WEB_ASSETS_DIR_ENV]); | 95 | const explicit = resolveIfExists(process.env[EXECUTOR_WEB_ASSETS_DIR_ENV]); |
| 81 | if (explicit) { | 96 | if (explicit) { |
| @@ -4,6 +4,8 @@ import { cn } from "../lib/utils"; · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | type LocalMcpInstallMode = "stdio" | "http"; | 5 | type LocalMcpInstallMode = "stdio" | "http"; |
| 6 | 6 | ||
| 7 | const isDevBuild = import.meta.env.DEV; | ||
| 8 | |||
| 7 | export function LocalMcpInstallCard(props: { | 9 | export function LocalMcpInstallCard(props: { |
| 8 | title?: string; | 10 | title?: string; |
| 9 | description?: string; | 11 | description?: string; |
| @@ -17,7 +19,9 @@ export function LocalMcpInstallCard(props: { · 7 unmodified lines | |||
| 17 | }, []); | 19 | }, []); |
| 18 | 20 | ||
| 19 | const command = mode === "stdio" | 21 | const command = mode === "stdio" |
| 20 | ? 'npx add-mcp "executor mcp --stdio" --name "executor-stdio"' | 22 | ? isDevBuild |
| 23 | ? 'npx add-mcp "bun run executor mcp --stdio" --name "executor-stdio"' | ||
| 24 | : 'npx add-mcp "executor mcp --stdio" --name "executor-stdio"' | ||
| 21 | : origin | 25 | : origin |
| 22 | ? `npx add-mcp "${origin}/mcp" --transport http --name "executor"` | 26 | ? `npx add-mcp "${origin}/mcp" --transport http --name "executor"` |
| 23 | : 'npx add-mcp "<this-server>/mcp" --transport http --name "executor"'; | 27 | : 'npx add-mcp "<this-server>/mcp" --transport http --name "executor"'; |
| @@ -57,9 +61,22 @@ export function LocalMcpInstallCard(props: { · 33 unmodified lines | |||
| 57 | </div> | 61 | </div> |
| 58 | <CodeBlock code={command} lang="bash" className="rounded-xl border border-border bg-background/70" /> | 62 | <CodeBlock code={command} lang="bash" className="rounded-xl border border-border bg-background/70" /> |
| 59 | {mode === "stdio" ? ( | 63 | {mode === "stdio" ? ( |
| 60 | <p className="mt-3 text-[12px] text-muted-foreground"> | 64 | <div className="mt-3 space-y-1 text-[12px] text-muted-foreground"> |
| 61 | Requires the `executor` CLI to be available on your PATH. Uses a distinct MCP name to avoid colliding with an existing remote `executor` entry. | 65 | {!isDevBuild ? ( |
| 62 | </p> | 66 | <p> |
| 67 | Requires the `executor` CLI on your PATH. Uses a distinct MCP name to avoid colliding with an existing remote `executor` entry. | ||
| 68 | </p> | ||
| 69 | ) : ( | ||
| 70 | <> | ||
| 71 | <p> | ||
| 72 | Uses the repo-local dev CLI: <code>bun run executor mcp --stdio</code>. | ||
| 73 | </p> | ||
| 74 | <p> | ||
| 75 | Run the `add-mcp` command from the repository root, or set your MCP client working directory to this repo before using the saved entry. | ||
| 76 | </p> | ||
| 77 | </> | ||
| 78 | )} | ||
| 79 | </div> | ||
| 63 | ) : null} | 80 | ) : null} |
| 64 | </section> | 81 | </section> |
| 65 | ); | 82 | ); |
| @@ -305,7 +305,7 @@ export function AppShell() { · 304 unmodified lines | |||
| 305 | </aside> | 305 | </aside> |
| 306 | 306 | ||
| 307 | {/* Main content */} | 307 | {/* Main content */} |
| 308 | <main className="flex flex-1 flex-col min-w-0 overflow-hidden"> | 308 | <main className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden"> |
| 309 | <Outlet /> | 309 | <Outlet /> |
| 310 | </main> | 310 | </main> |
| 311 | </div> | 311 | </div> |
| @@ -10,19 +10,16 @@ export function SourcePluginsResetState( · 9 unmodified lines | |||
| 10 | input: SourcePluginsResetStateProps, | 10 | input: SourcePluginsResetStateProps, |
| 11 | ) { | 11 | ) { |
| 12 | return ( | 12 | return ( |
| 13 | <div className="flex-1 overflow-y-auto"> | 13 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 14 | <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14"> | 14 | <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14"> |
| 15 | <div className="rounded-3xl border border-border bg-card p-8 shadow-[0_20px_60px_-40px_rgba(15,23,42,0.35)]"> | 15 | <div className="rounded-2xl border border-border bg-card p-8"> |
| 16 | <div className="inline-flex rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground"> | 16 | <h2 className="font-display text-xl tracking-tight text-foreground"> |
| 17 | Clean Slate | ||
| 18 | </div> | ||
| 19 | <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground"> | ||
| 20 | {input.title} | 17 | {input.title} |
| 21 | </h1> | 18 | </h2> |
| 22 | <p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground"> | 19 | <p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground"> |
| 23 | {input.message} | 20 | {input.message} |
| 24 | </p> | 21 | </p> |
| 25 | {input.action ? <div className="mt-6">{input.action}</div> : null} | 22 | {input.action ? <div className="mt-5">{input.action}</div> : null} |
| 26 | </div> | 23 | </div> |
| 27 | </div> | 24 | </div> |
| 28 | </div> | 25 | </div> |
| @@ -144,15 +144,19 @@ const sourcePluginRoutes = registeredSourceFrontendTypes.flatMap((definition) => · 143 unmodified lines | |||
| 144 | const paths = createSourcePluginPaths(definition.key); | 144 | const paths = createSourcePluginPaths(definition.key); |
| 145 | 145 | ||
| 146 | const AddRouteComponent = () => { | 146 | const AddRouteComponent = () => { |
| 147 | const search = addRoute.useSearch(); | ||
| 147 | const navigate = useNavigate(); | 148 | const navigate = useNavigate(); |
| 149 | const navigateFromRoute = useNavigate({ from: addRoute.fullPath }); | ||
| 148 | const navigation = createSourcePluginNavigation(definition, { | 150 | const navigation = createSourcePluginNavigation(definition, { |
| 149 | navigateTo: (to, search) => | 151 | navigateTo: (to, search) => |
| 150 | search === undefined ? navigate({ to }) : navigate({ to, search }), | 152 | search === undefined ? navigate({ to }) : navigate({ to, search }), |
| 153 | updateSearch: (nextSearch) => navigateFromRoute({ search: nextSearch }), | ||
| 151 | }); | 154 | }); |
| 152 | 155 | ||
| 153 | return ( | 156 | return ( |
| 154 | <SourcePluginAddPage | 157 | <SourcePluginAddPage |
| 155 | definitionKey={definition.key} | 158 | definitionKey={definition.key} |
| 159 | search={search} | ||
| 156 | navigation={navigation} | 160 | navigation={navigation} |
| 157 | /> | 161 | /> |
| 158 | ); | 162 | ); |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { Link } from "@tanstack/react-router"; | 1 | import { useState, type FormEvent } from "react"; |
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | ||
| 2 | import { useSource } from "@executor/react"; | 3 | import { useSource } from "@executor/react"; |
| 3 | import { | 4 | import { |
| 4 | ExecutorPluginRouteProvider, | 5 | ExecutorPluginRouteProvider, |
| @@ -15,6 +16,8 @@ import { · 10 unmodified lines | |||
| 15 | type SourcePluginRouteSearch, | 16 | type SourcePluginRouteSearch, |
| 16 | } from "@executor/react/plugins"; | 17 | } from "@executor/react/plugins"; |
| 17 | 18 | ||
| 19 | import { DefaultSourceIcon } from "../components/source-favicon"; | ||
| 20 | import { LocalMcpInstallCard } from "../components/local-mcp-install-card"; | ||
| 18 | import { SourcePluginsResetState } from "../components/source-plugins-reset-state"; | 21 | import { SourcePluginsResetState } from "../components/source-plugins-reset-state"; |
| 19 | import { | 22 | import { |
| 20 | getFrontendPlugin, | 23 | getFrontendPlugin, |
| @@ -23,18 +26,23 @@ import { · 2 unmodified lines | |||
| 23 | getSourceFrontendTypeEntryByKey, | 26 | getSourceFrontendTypeEntryByKey, |
| 24 | registeredSourceFrontendTypes, | 27 | registeredSourceFrontendTypes, |
| 25 | } from "./index"; | 28 | } from "./index"; |
| 29 | import { | ||
| 30 | buildSourcePresetSearch, | ||
| 31 | resolveQuickSourceInput, | ||
| 32 | sourcePresets, | ||
| 33 | } from "./source-presets"; | ||
| 26 | 34 | ||
| 27 | const FrontendPluginUnavailableState = () => ( | 35 | const FrontendPluginUnavailableState = () => ( |
| 28 | <SourcePluginsResetState | 36 | <SourcePluginsResetState |
| 29 | title="Frontend plugins are unavailable" | 37 | title="Plugins unavailable" |
| 30 | message="No frontend plugins are registered in this build." | 38 | message="No plugins are available in this build." |
| 31 | /> | 39 | /> |
| 32 | ); | 40 | ); |
| 33 | 41 | ||
| 34 | const SourcePluginUnavailableState = () => ( | 42 | const SourcePluginUnavailableState = () => ( |
| 35 | <SourcePluginsResetState | 43 | <SourcePluginsResetState |
| 36 | title="Source plugins are unavailable" | 44 | title="No source types available" |
| 37 | message="No source plugins are registered in this build." | 45 | message="No source types are available in this build." |
| 38 | /> | 46 | /> |
| 39 | ); | 47 | ); |
| 40 | 48 | ||
| @@ -69,11 +77,69 @@ const SourcePluginRouteMismatchState = (props: { · 28 unmodified lines | |||
| 69 | actualDisplayName: string; | 77 | actualDisplayName: string; |
| 70 | }) => ( | 78 | }) => ( |
| 71 | <SourcePluginsResetState | 79 | <SourcePluginsResetState |
| 72 | title="Source route mismatch" | 80 | title="Wrong source type" |
| 73 | message={`This source belongs to ${props.actualDisplayName}, but the current route is mounted for ${props.requestedDisplayName}. Reopen it from the source list to use the canonical plugin route.`} | 81 | message={`This source uses ${props.actualDisplayName}, but you navigated to ${props.requestedDisplayName}. Open it from the sidebar instead.`} |
| 74 | /> | 82 | /> |
| 75 | ); | 83 | ); |
| 76 | 84 | ||
| 85 | const SourceQuickEntry = () => { | ||
| 86 | const navigate = useNavigate(); | ||
| 87 | const [value, setValue] = useState(""); | ||
| 88 | const [error, setError] = useState<string | null>(null); | ||
| 89 | |||
| 90 | const handleSubmit = (event: FormEvent<HTMLFormElement>) => { | ||
| 91 | event.preventDefault(); | ||
| 92 | |||
| 93 | const resolved = resolveQuickSourceInput(value); | ||
| 94 | if (resolved === null) { | ||
| 95 | setError( | ||
| 96 | "Could not recognize that input. Try a GraphQL endpoint, MCP URL, Google Discovery URL, OpenAPI spec URL, or a local MCP command.", | ||
| 97 | ); | ||
| 98 | return; | ||
| 99 | } | ||
| 100 | |||
| 101 | setError(null); | ||
| 102 | void navigate({ | ||
| 103 | to: createSourcePluginPaths(resolved.pluginKey).add, | ||
| 104 | search: resolved.search, | ||
| 105 | }); | ||
| 106 | }; | ||
| 107 | |||
| 108 | return ( | ||
| 109 | <div className="rounded-2xl border border-border bg-card p-6"> | ||
| 110 | <h2 className="font-display text-lg font-semibold tracking-tight text-foreground"> | ||
| 111 | Quick add | ||
| 112 | </h2> | ||
| 113 | <p className="mt-1 text-sm leading-6 text-muted-foreground"> | ||
| 114 | Paste a URL or command and we'll detect the right plugin automatically. | ||
| 115 | </p> | ||
| 116 | |||
| 117 | <form onSubmit={handleSubmit} className="mt-4"> | ||
| 118 | <div className="flex flex-col gap-3 sm:flex-row"> | ||
| 119 | <input | ||
| 120 | value={value} | ||
| 121 | onChange={(event) => setValue(event.target.value)} | ||
| 122 | placeholder="https://api.github.com/graphql or npx -y chrome-devtools-mcp@latest" | ||
| 123 | className="h-10 flex-1 rounded-lg border border-input bg-background px-3.5 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground/45 focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 124 | /> | ||
| 125 | <button | ||
| 126 | type="submit" | ||
| 127 | className="inline-flex h-10 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90" | ||
| 128 | > | ||
| 129 | Add source | ||
| 130 | </button> | ||
| 131 | </div> | ||
| 132 | |||
| 133 | {error && ( | ||
| 134 | <div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive"> | ||
| 135 | {error} | ||
| 136 | </div> | ||
| 137 | )} | ||
| 138 | </form> | ||
| 139 | </div> | ||
| 140 | ); | ||
| 141 | }; | ||
| 142 | |||
| 77 | export function ExecutorPluginRoutePage(input: { | 143 | export function ExecutorPluginRoutePage(input: { |
| 78 | pluginKey: string; | 144 | pluginKey: string; |
| 79 | routeKey: FrontendPluginRouteDefinition["key"]; | 145 | routeKey: FrontendPluginRouteDefinition["key"]; |
| @@ -90,8 +156,8 @@ export function ExecutorPluginRoutePage(input: { · 10 unmodified lines | |||
| 90 | if (routeEntry === null) { | 156 | if (routeEntry === null) { |
| 91 | return ( | 157 | return ( |
| 92 | <SourcePluginsResetState | 158 | <SourcePluginsResetState |
| 93 | title="Plugin route is unavailable" | 159 | title="Route unavailable" |
| 94 | message={`No plugin route is registered for "${input.routeKey}" in "${input.pluginKey}".`} | 160 | message={`The route "${input.routeKey}" is not available for this plugin.`} |
| 95 | /> | 161 | /> |
| 96 | ); | 162 | ); |
| 97 | } | 163 | } |
| @@ -120,46 +186,126 @@ export function SourcePluginsIndexPage() { · 22 unmodified lines | |||
| 120 | return <SourcePluginUnavailableState />; | 186 | return <SourcePluginUnavailableState />; |
| 121 | } | 187 | } |
| 122 | 188 | ||
| 189 | const hasMcpPlugin = registeredSourceFrontendTypes.some( | ||
| 190 | (definition) => definition.key === "mcp", | ||
| 191 | ); | ||
| 192 | |||
| 193 | const presetsByPlugin = registeredSourceFrontendTypes | ||
| 194 | .map((definition) => ({ | ||
| 195 | definition, | ||
| 196 | presets: sourcePresets.filter((preset) => preset.pluginKey === definition.key), | ||
| 197 | })) | ||
| 198 | .filter((entry) => entry.presets.length > 0); | ||
| 199 | |||
| 123 | return ( | 200 | return ( |
| 124 | <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14"> | 201 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 125 | <div className="rounded-3xl border border-border bg-card p-8"> | 202 | <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14"> |
| 126 | <div className="inline-flex rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground"> | 203 | {/* Header */} |
| 127 | Source Plugins | 204 | <div className="mb-8"> |
| 205 | <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | ||
| 206 | Add source | ||
| 207 | </h1> | ||
| 208 | <p className="mt-1.5 text-[14px] text-muted-foreground"> | ||
| 209 | Connect a new tool provider to this workspace. | ||
| 210 | </p> | ||
| 128 | </div> | 211 | </div> |
| 129 | <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | ||
| 130 | Choose a source plugin | ||
| 131 | </h1> | ||
| 132 | <p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground"> | ||
| 133 | Each plugin owns its own connection flow, detail routes, and UI. | ||
| 134 | Pick the plugin that should own this source. | ||
| 135 | </p> | ||
| 136 | </div> | ||
| 137 | 212 | ||
| 138 | <div className="mt-8 grid gap-4 md:grid-cols-2"> | 213 | <SourceQuickEntry /> |
| 139 | {registeredSourceFrontendTypes.map((definition) => { | 214 | |
| 140 | const paths = createSourcePluginPaths(definition.key); | 215 | {hasMcpPlugin && ( |
| 216 | <LocalMcpInstallCard | ||
| 217 | className="mt-8" | ||
| 218 | title="Install this executor as MCP" | ||
| 219 | description="Prefer a one-command setup? Install this local executor server into your MCP client, or add an external MCP source below." | ||
| 220 | /> | ||
| 221 | )} | ||
| 222 | |||
| 223 | {/* Presets */} | ||
| 224 | {presetsByPlugin.length > 0 && ( | ||
| 225 | <div className="mt-10"> | ||
| 226 | <h2 className="text-lg font-semibold text-foreground">Popular sources</h2> | ||
| 227 | <p className="mt-1 text-sm leading-6 text-muted-foreground"> | ||
| 228 | One-click presets for common APIs and tools. | ||
| 229 | </p> | ||
| 141 | 230 | ||
| 142 | return ( | 231 | <div className="mt-5 space-y-8"> |
| 143 | <Link | 232 | {presetsByPlugin.map(({ definition, presets }) => { |
| 144 | key={definition.key} | 233 | const paths = createSourcePluginPaths(definition.key); |
| 145 | to={paths.add} | 234 | |
| 146 | className="group rounded-3xl border border-border bg-card p-6 transition-colors hover:border-primary/30 hover:bg-card/90" | 235 | return ( |
| 147 | > | 236 | <section key={definition.key}> |
| 148 | <div className="flex h-full flex-col"> | 237 | <h3 className="mb-3 text-sm font-semibold text-foreground"> |
| 149 | <div className="text-lg font-semibold text-foreground"> | 238 | {definition.displayName} |
| 150 | {definition.displayName} | 239 | </h3> |
| 151 | </div> | 240 | |
| 152 | <div className="mt-2 flex-1 text-sm leading-6 text-muted-foreground"> | 241 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> |
| 153 | {definition.description | 242 | {presets.map((preset) => ( |
| 154 | ?? "Open the plugin-owned source flow."} | 243 | <Link |
| 155 | </div> | 244 | key={preset.id} |
| 156 | <div className="mt-6 text-xs font-medium uppercase tracking-[0.2em] text-primary"> | 245 | to={paths.add} |
| 157 | Open Plugin | 246 | search={buildSourcePresetSearch(preset)} |
| 158 | </div> | 247 | className="rounded-xl border border-border bg-card px-4 py-3 text-left transition-colors hover:border-primary/25 hover:bg-card/90" |
| 159 | </div> | 248 | > |
| 160 | </Link> | 249 | <div className="mb-2 flex items-center gap-2.5"> |
| 161 | ); | 250 | <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> |
| 162 | })} | 251 | <DefaultSourceIcon kind={preset.kind} className="size-4" /> |
| 252 | </div> | ||
| 253 | <div className="min-w-0"> | ||
| 254 | <div className="truncate text-sm font-semibold text-foreground"> | ||
| 255 | {preset.name} | ||
| 256 | </div> | ||
| 257 | <div className="mt-0.5 text-[11px] text-muted-foreground"> | ||
| 258 | {preset.kind} | ||
| 259 | </div> | ||
| 260 | </div> | ||
| 261 | </div> | ||
| 262 | <div className="text-xs leading-5 text-muted-foreground"> | ||
| 263 | {preset.summary} | ||
| 264 | </div> | ||
| 265 | </Link> | ||
| 266 | ))} | ||
| 267 | </div> | ||
| 268 | </section> | ||
| 269 | ); | ||
| 270 | })} | ||
| 271 | </div> | ||
| 272 | </div> | ||
| 273 | )} | ||
| 274 | |||
| 275 | {/* Browse by plugin type */} | ||
| 276 | <div className="mt-10"> | ||
| 277 | <h2 className="text-lg font-semibold text-foreground">By type</h2> | ||
| 278 | <p className="mt-1 text-sm leading-6 text-muted-foreground"> | ||
| 279 | Start from scratch with a specific plugin. | ||
| 280 | </p> | ||
| 281 | |||
| 282 | <div className="mt-5 grid gap-3 md:grid-cols-2"> | ||
| 283 | {registeredSourceFrontendTypes.map((definition) => { | ||
| 284 | const paths = createSourcePluginPaths(definition.key); | ||
| 285 | |||
| 286 | return ( | ||
| 287 | <Link | ||
| 288 | key={definition.key} | ||
| 289 | to={paths.add} | ||
| 290 | className="group rounded-xl border border-border bg-card p-5 transition-colors hover:border-primary/25 hover:bg-card/90" | ||
| 291 | > | ||
| 292 | <div className="flex h-full flex-col"> | ||
| 293 | <div className="text-sm font-semibold text-foreground"> | ||
| 294 | {definition.displayName} | ||
| 295 | </div> | ||
| 296 | <div className="mt-1.5 flex-1 text-sm leading-6 text-muted-foreground"> | ||
| 297 | {definition.description | ||
| 298 | ?? "Configure a new source from scratch."} | ||
| 299 | </div> | ||
| 300 | <div className="mt-4 text-xs font-medium text-primary"> | ||
| 301 | Get started → | ||
| 302 | </div> | ||
| 303 | </div> | ||
| 304 | </Link> | ||
| 305 | ); | ||
| 306 | })} | ||
| 307 | </div> | ||
| 308 | </div> | ||
| 163 | </div> | 309 | </div> |
| 164 | </div> | 310 | </div> |
| 165 | ); | 311 | ); |
| @@ -167,6 +313,7 @@ export function SourcePluginsIndexPage() { · 1 unmodified line | |||
| 167 | 313 | ||
| 168 | export function SourcePluginAddPage(props: { | 314 | export function SourcePluginAddPage(props: { |
| 169 | definitionKey: string; | 315 | definitionKey: string; |
| 316 | search?: SourcePluginRouteSearch; | ||
| 170 | navigation: SourcePluginNavigation; | 317 | navigation: SourcePluginNavigation; |
| 171 | }) { | 318 | }) { |
| 172 | const entry = getSourceFrontendTypeEntryByKey(props.definitionKey); | 319 | const entry = getSourceFrontendTypeEntryByKey(props.definitionKey); |
| @@ -175,6 +322,7 @@ export function SourcePluginAddPage(props: { · 2 unmodified lines | |||
| 175 | } | 322 | } |
| 176 | 323 | ||
| 177 | const AddPage = entry.definition.renderAddPage; | 324 | const AddPage = entry.definition.renderAddPage; |
| 325 | const search = props.search ?? {}; | ||
| 178 | 326 | ||
| 179 | return ( | 327 | return ( |
| 180 | <SourcePluginRouteProvider | 328 | <SourcePluginRouteProvider |
| @@ -182,27 +330,26 @@ export function SourcePluginAddPage(props: { · 1 unmodified line | |||
| 182 | plugin: entry.plugin, | 330 | plugin: entry.plugin, |
| 183 | definition: entry.definition, | 331 | definition: entry.definition, |
| 184 | params: {}, | 332 | params: {}, |
| 185 | search: {}, | 333 | search, |
| 186 | navigation: props.navigation, | 334 | navigation: props.navigation, |
| 187 | }} | 335 | }} |
| 188 | > | 336 | > |
| 189 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> | 337 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 190 | <div className="mb-8"> | 338 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> |
| 191 | <div className="inline-flex rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground"> | 339 | <div className="mb-8"> |
| 192 | Source Plugin | 340 | <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> |
| 341 | {entry.definition.displayName} | ||
| 342 | </h1> | ||
| 343 | {entry.definition.description && ( | ||
| 344 | <p className="mt-1.5 text-sm leading-6 text-muted-foreground"> | ||
| 345 | {entry.definition.description} | ||
| 346 | </p> | ||
| 347 | )} | ||
| 193 | </div> | 348 | </div> |
| 194 | <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | ||
| 195 | {entry.definition.displayName} | ||
| 196 | </h1> | ||
| 197 | {entry.definition.description && ( | ||
| 198 | <p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground"> | ||
| 199 | {entry.definition.description} | ||
| 200 | </p> | ||
| 201 | )} | ||
| 202 | </div> | ||
| 203 | 349 | ||
| 204 | <SourcePluginPicker activeKey={entry.definition.key} /> | 350 | <SourcePluginPicker activeKey={entry.definition.key} /> |
| 205 | <AddPage /> | 351 | <AddPage key={JSON.stringify(search)} /> |
| 352 | </div> | ||
| 206 | </div> | 353 | </div> |
| 207 | </SourcePluginRouteProvider> | 354 | </SourcePluginRouteProvider> |
| 208 | ); | 355 | ); |
| @@ -229,8 +376,8 @@ export function SourcePluginEditPage(input: { · 20 unmodified lines | |||
| 229 | if (entry === null) { | 376 | if (entry === null) { |
| 230 | return ( | 377 | return ( |
| 231 | <SourcePluginsResetState | 378 | <SourcePluginsResetState |
| 232 | title="Source plugin is unavailable" | 379 | title="Source type unavailable" |
| 233 | message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`} | 380 | message={`No plugin is available for source type "${loadedSource.kind}".`} |
| 234 | /> | 381 | /> |
| 235 | ); | 382 | ); |
| 236 | } | 383 | } |
| @@ -247,8 +394,8 @@ export function SourcePluginEditPage(input: { · 10 unmodified lines | |||
| 247 | if (entry.definition.renderEditPage === undefined) { | 394 | if (entry.definition.renderEditPage === undefined) { |
| 248 | return ( | 395 | return ( |
| 249 | <SourcePluginsResetState | 396 | <SourcePluginsResetState |
| 250 | title="Source editing is disabled" | 397 | title="Editing unavailable" |
| 251 | message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`} | 398 | message={`No plugin is available for source type "${loadedSource.kind}".`} |
| 252 | /> | 399 | /> |
| 253 | ); | 400 | ); |
| 254 | } | 401 | } |
| @@ -298,8 +445,8 @@ export function SourcePluginDetailPage(input: { · 43 unmodified lines | |||
| 298 | if (entry === null) { | 445 | if (entry === null) { |
| 299 | return ( | 446 | return ( |
| 300 | <SourcePluginsResetState | 447 | <SourcePluginsResetState |
| 301 | title="Source plugin is unavailable" | 448 | title="Source type unavailable" |
| 302 | message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`} | 449 | message={`No plugin is available for source type "${loadedSource.kind}".`} |
| 303 | /> | 450 | /> |
| 304 | ); | 451 | ); |
| 305 | } | 452 | } |
| @@ -316,8 +463,8 @@ export function SourcePluginDetailPage(input: { · 10 unmodified lines | |||
| 316 | if (entry.definition.renderDetailPage === undefined) { | 463 | if (entry.definition.renderDetailPage === undefined) { |
| 317 | return ( | 464 | return ( |
| 318 | <SourcePluginsResetState | 465 | <SourcePluginsResetState |
| 319 | title="Source detail is disabled" | 466 | title="Details unavailable" |
| 320 | message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`} | 467 | message={`No plugin is available for source type "${loadedSource.kind}".`} |
| 321 | /> | 468 | /> |
| 322 | ); | 469 | ); |
| 323 | } | 470 | } |
| @@ -368,8 +515,8 @@ export function SourcePluginDetailChildPage(input: { · 44 unmodified lines | |||
| 368 | if (entry === null) { | 515 | if (entry === null) { |
| 369 | return ( | 516 | return ( |
| 370 | <SourcePluginsResetState | 517 | <SourcePluginsResetState |
| 371 | title="Source plugin is unavailable" | 518 | title="Source type unavailable" |
| 372 | message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`} | 519 | message={`No plugin is available for source type "${loadedSource.kind}".`} |
| 373 | /> | 520 | /> |
| 374 | ); | 521 | ); |
| 375 | } | 522 | } |
| @@ -390,8 +537,8 @@ export function SourcePluginDetailChildPage(input: { · 14 unmodified lines | |||
| 390 | if (!detailRoute) { | 537 | if (!detailRoute) { |
| 391 | return ( | 538 | return ( |
| 392 | <SourcePluginsResetState | 539 | <SourcePluginsResetState |
| 393 | title="Source route is unavailable" | 540 | title="Route unavailable" |
| 394 | message={`No plugin-owned detail route is registered for "${input.routeKey}".`} | 541 | message={`The route "${input.routeKey}" is not available for this source type.`} |
| 395 | /> | 542 | /> |
| 396 | ); | 543 | ); |
| 397 | } | 544 | } |
| @@ -0,0 +1,657 @@ | |||
| 1 | const GOOGLE_DISCOVERY_PLUGIN_KEY = "google-discovery" as const; | ||
| 2 | const GOOGLE_DISCOVERY_SOURCE_KIND = "google_discovery" as const; | ||
| 3 | |||
| 4 | export type SourcePreset = | ||
| 5 | | { | ||
| 6 | id: string; | ||
| 7 | pluginKey: "mcp"; | ||
| 8 | kind: "mcp"; | ||
| 9 | name: string; | ||
| 10 | summary: string; | ||
| 11 | previewUrl: string; | ||
| 12 | endpoint?: string; | ||
| 13 | transport?: "auto" | "streamable-http" | "sse" | "stdio"; | ||
| 14 | command?: string; | ||
| 15 | args?: string[]; | ||
| 16 | env?: Record<string, string>; | ||
| 17 | cwd?: string; | ||
| 18 | } | ||
| 19 | | { | ||
| 20 | id: string; | ||
| 21 | pluginKey: "openapi"; | ||
| 22 | kind: "openapi"; | ||
| 23 | name: string; | ||
| 24 | summary: string; | ||
| 25 | previewUrl: string; | ||
| 26 | baseUrl: string; | ||
| 27 | specUrl: string; | ||
| 28 | } | ||
| 29 | | { | ||
| 30 | id: string; | ||
| 31 | pluginKey: "graphql"; | ||
| 32 | kind: "graphql"; | ||
| 33 | name: string; | ||
| 34 | summary: string; | ||
| 35 | previewUrl: string; | ||
| 36 | endpoint: string; | ||
| 37 | } | ||
| 38 | | { | ||
| 39 | id: string; | ||
| 40 | pluginKey: typeof GOOGLE_DISCOVERY_PLUGIN_KEY; | ||
| 41 | kind: typeof GOOGLE_DISCOVERY_SOURCE_KIND; | ||
| 42 | name: string; | ||
| 43 | summary: string; | ||
| 44 | previewUrl: string; | ||
| 45 | service: string; | ||
| 46 | version: string; | ||
| 47 | discoveryUrl: string; | ||
| 48 | }; | ||
| 49 | |||
| 50 | const googleDiscoveryPreset = (input: { | ||
| 51 | id: string; | ||
| 52 | name: string; | ||
| 53 | summary: string; | ||
| 54 | service: string; | ||
| 55 | version: string; | ||
| 56 | discoveryUrl: string; | ||
| 57 | }): SourcePreset => ({ | ||
| 58 | id: input.id, | ||
| 59 | pluginKey: GOOGLE_DISCOVERY_PLUGIN_KEY, | ||
| 60 | kind: GOOGLE_DISCOVERY_SOURCE_KIND, | ||
| 61 | name: input.name, | ||
| 62 | summary: input.summary, | ||
| 63 | previewUrl: input.discoveryUrl, | ||
| 64 | service: input.service, | ||
| 65 | version: input.version, | ||
| 66 | discoveryUrl: input.discoveryUrl, | ||
| 67 | }); | ||
| 68 | |||
| 69 | export const sourcePresets: ReadonlyArray<SourcePreset> = [ | ||
| 70 | { | ||
| 71 | id: "deepwiki-mcp", | ||
| 72 | pluginKey: "mcp", | ||
| 73 | kind: "mcp", | ||
| 74 | name: "DeepWiki MCP", | ||
| 75 | summary: "Repository docs and knowledge graphs via MCP.", | ||
| 76 | previewUrl: "https://mcp.deepwiki.com/mcp", | ||
| 77 | endpoint: "https://mcp.deepwiki.com/mcp", | ||
| 78 | transport: "auto", | ||
| 79 | }, | ||
| 80 | { | ||
| 81 | id: "axiom-mcp", | ||
| 82 | pluginKey: "mcp", | ||
| 83 | kind: "mcp", | ||
| 84 | name: "Axiom MCP", | ||
| 85 | summary: "Query, stream, and analyze logs, traces, and event data.", | ||
| 86 | previewUrl: "https://mcp.axiom.co/mcp", | ||
| 87 | endpoint: "https://mcp.axiom.co/mcp", | ||
| 88 | transport: "auto", | ||
| 89 | }, | ||
| 90 | { | ||
| 91 | id: "neon-mcp", | ||
| 92 | pluginKey: "mcp", | ||
| 93 | kind: "mcp", | ||
| 94 | name: "Neon MCP", | ||
| 95 | summary: "Manage Postgres databases, branches, and queries via MCP.", | ||
| 96 | previewUrl: "https://mcp.neon.tech/mcp", | ||
| 97 | endpoint: "https://mcp.neon.tech/mcp", | ||
| 98 | transport: "auto", | ||
| 99 | }, | ||
| 100 | { | ||
| 101 | id: "chrome-devtools-mcp", | ||
| 102 | pluginKey: "mcp", | ||
| 103 | kind: "mcp", | ||
| 104 | name: "Chrome DevTools MCP", | ||
| 105 | summary: "Debug a live Chrome browser session over a local MCP stdio transport.", | ||
| 106 | previewUrl: "npx -y chrome-devtools-mcp@latest", | ||
| 107 | transport: "stdio", | ||
| 108 | command: "npx", | ||
| 109 | args: ["-y", "chrome-devtools-mcp@latest"], | ||
| 110 | }, | ||
| 111 | { | ||
| 112 | id: "neon-api", | ||
| 113 | pluginKey: "openapi", | ||
| 114 | kind: "openapi", | ||
| 115 | name: "Neon API", | ||
| 116 | summary: "Projects, branches, endpoints, databases, and API keys.", | ||
| 117 | previewUrl: "https://console.neon.tech/api/v2", | ||
| 118 | baseUrl: "https://console.neon.tech/api/v2", | ||
| 119 | specUrl: "https://neon.com/api_spec/release/v2.json", | ||
| 120 | }, | ||
| 121 | { | ||
| 122 | id: "github-rest", | ||
| 123 | pluginKey: "openapi", | ||
| 124 | kind: "openapi", | ||
| 125 | name: "GitHub REST API", | ||
| 126 | summary: "Repos, issues, pull requests, actions, and org settings.", | ||
| 127 | previewUrl: "https://api.github.com", | ||
| 128 | baseUrl: "https://api.github.com", | ||
| 129 | specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", | ||
| 130 | }, | ||
| 131 | { | ||
| 132 | id: "github-graphql", | ||
| 133 | pluginKey: "graphql", | ||
| 134 | kind: "graphql", | ||
| 135 | name: "GitHub GraphQL", | ||
| 136 | summary: "Issues, pull requests, discussions, and repository objects via GraphQL.", | ||
| 137 | previewUrl: "https://api.github.com/graphql", | ||
| 138 | endpoint: "https://api.github.com/graphql", | ||
| 139 | }, | ||
| 140 | { | ||
| 141 | id: "gitlab-graphql", | ||
| 142 | pluginKey: "graphql", | ||
| 143 | kind: "graphql", | ||
| 144 | name: "GitLab GraphQL", | ||
| 145 | summary: "Projects, merge requests, issues, CI pipelines, and users.", | ||
| 146 | previewUrl: "https://gitlab.com/api/graphql", | ||
| 147 | endpoint: "https://gitlab.com/api/graphql", | ||
| 148 | }, | ||
| 149 | { | ||
| 150 | id: "openai-api", | ||
| 151 | pluginKey: "openapi", | ||
| 152 | kind: "openapi", | ||
| 153 | name: "OpenAI API", | ||
| 154 | summary: "Models, files, responses, and fine-tuning.", | ||
| 155 | previewUrl: "https://api.openai.com/v1", | ||
| 156 | baseUrl: "https://api.openai.com/v1", | ||
| 157 | specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", | ||
| 158 | }, | ||
| 159 | { | ||
| 160 | id: "vercel-api", | ||
| 161 | pluginKey: "openapi", | ||
| 162 | kind: "openapi", | ||
| 163 | name: "Vercel API", | ||
| 164 | summary: "Deployments, projects, domains, and environments.", | ||
| 165 | previewUrl: "https://api.vercel.com", | ||
| 166 | baseUrl: "https://api.vercel.com", | ||
| 167 | specUrl: "https://openapi.vercel.sh", | ||
| 168 | }, | ||
| 169 | { | ||
| 170 | id: "stripe-api", | ||
| 171 | pluginKey: "openapi", | ||
| 172 | kind: "openapi", | ||
| 173 | name: "Stripe API", | ||
| 174 | summary: "Payments, billing, subscriptions, and invoices.", | ||
| 175 | previewUrl: "https://api.stripe.com", | ||
| 176 | baseUrl: "https://api.stripe.com", | ||
| 177 | specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", | ||
| 178 | }, | ||
| 179 | { | ||
| 180 | id: "linear-graphql", | ||
| 181 | pluginKey: "graphql", | ||
| 182 | kind: "graphql", | ||
| 183 | name: "Linear GraphQL", | ||
| 184 | summary: "Issues, teams, cycles, and projects.", | ||
| 185 | previewUrl: "https://api.linear.app/graphql", | ||
| 186 | endpoint: "https://api.linear.app/graphql", | ||
| 187 | }, | ||
| 188 | { | ||
| 189 | id: "monday-graphql", | ||
| 190 | pluginKey: "graphql", | ||
| 191 | kind: "graphql", | ||
| 192 | name: "Monday GraphQL", | ||
| 193 | summary: "Boards, items, updates, users, and workspace metadata.", | ||
| 194 | previewUrl: "https://api.monday.com/v2", | ||
| 195 | endpoint: "https://api.monday.com/v2", | ||
| 196 | }, | ||
| 197 | { | ||
| 198 | id: "anilist-graphql", | ||
| 199 | pluginKey: "graphql", | ||
| 200 | kind: "graphql", | ||
| 201 | name: "AniList GraphQL", | ||
| 202 | summary: "Anime, manga, characters, media lists, and recommendations.", | ||
| 203 | previewUrl: "https://graphql.anilist.co", | ||
| 204 | endpoint: "https://graphql.anilist.co", | ||
| 205 | }, | ||
| 206 | googleDiscoveryPreset({ | ||
| 207 | id: "google-calendar", | ||
| 208 | name: "Google Calendar", | ||
| 209 | summary: "Calendars, events, ACLs, and scheduling workflows.", | ||
| 210 | service: "calendar", | ||
| 211 | version: "v3", | ||
| 212 | discoveryUrl: "https://calendar-json.googleapis.com/$discovery/rest?version=v3", | ||
| 213 | }), | ||
| 214 | googleDiscoveryPreset({ | ||
| 215 | id: "google-drive", | ||
| 216 | name: "Google Drive", | ||
| 217 | summary: "Files, folders, permissions, comments, and shared drives.", | ||
| 218 | service: "drive", | ||
| 219 | version: "v3", | ||
| 220 | discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest", | ||
| 221 | }), | ||
| 222 | googleDiscoveryPreset({ | ||
| 223 | id: "google-gmail", | ||
| 224 | name: "Gmail", | ||
| 225 | summary: "Messages, threads, labels, drafts, and mailbox automation.", | ||
| 226 | service: "gmail", | ||
| 227 | version: "v1", | ||
| 228 | discoveryUrl: "https://gmail.googleapis.com/$discovery/rest?version=v1", | ||
| 229 | }), | ||
| 230 | googleDiscoveryPreset({ | ||
| 231 | id: "google-docs", | ||
| 232 | name: "Google Docs", | ||
| 233 | summary: "Documents, structural edits, text ranges, and formatting.", | ||
| 234 | service: "docs", | ||
| 235 | version: "v1", | ||
| 236 | discoveryUrl: "https://docs.googleapis.com/$discovery/rest?version=v1", | ||
| 237 | }), | ||
| 238 | googleDiscoveryPreset({ | ||
| 239 | id: "google-sheets", | ||
| 240 | name: "Google Sheets", | ||
| 241 | summary: "Spreadsheets, values, ranges, formatting, and batch updates.", | ||
| 242 | service: "sheets", | ||
| 243 | version: "v4", | ||
| 244 | discoveryUrl: "https://sheets.googleapis.com/$discovery/rest?version=v4", | ||
| 245 | }), | ||
| 246 | googleDiscoveryPreset({ | ||
| 247 | id: "google-slides", | ||
| 248 | name: "Google Slides", | ||
| 249 | summary: "Presentations, slides, page elements, and deck updates.", | ||
| 250 | service: "slides", | ||
| 251 | version: "v1", | ||
| 252 | discoveryUrl: "https://slides.googleapis.com/$discovery/rest?version=v1", | ||
| 253 | }), | ||
| 254 | googleDiscoveryPreset({ | ||
| 255 | id: "google-forms", | ||
| 256 | name: "Google Forms", | ||
| 257 | summary: "Forms, questions, responses, quizzes, and form metadata.", | ||
| 258 | service: "forms", | ||
| 259 | version: "v1", | ||
| 260 | discoveryUrl: "https://forms.googleapis.com/$discovery/rest?version=v1", | ||
| 261 | }), | ||
| 262 | googleDiscoveryPreset({ | ||
| 263 | id: "google-search-console", | ||
| 264 | name: "Google Search Console", | ||
| 265 | summary: "Sites, sitemaps, URL inspection, and search and Discover performance.", | ||
| 266 | service: "searchconsole", | ||
| 267 | version: "v1", | ||
| 268 | discoveryUrl: "https://searchconsole.googleapis.com/$discovery/rest?version=v1", | ||
| 269 | }), | ||
| 270 | googleDiscoveryPreset({ | ||
| 271 | id: "google-people", | ||
| 272 | name: "Google People", | ||
| 273 | summary: "Contacts, profiles, directory people, and contact groups.", | ||
| 274 | service: "people", | ||
| 275 | version: "v1", | ||
| 276 | discoveryUrl: "https://people.googleapis.com/$discovery/rest?version=v1", | ||
| 277 | }), | ||
| 278 | googleDiscoveryPreset({ | ||
| 279 | id: "google-tasks", | ||
| 280 | name: "Google Tasks", | ||
| 281 | summary: "Task lists, task items, notes, and due dates.", | ||
| 282 | service: "tasks", | ||
| 283 | version: "v1", | ||
| 284 | discoveryUrl: "https://tasks.googleapis.com/$discovery/rest?version=v1", | ||
| 285 | }), | ||
| 286 | googleDiscoveryPreset({ | ||
| 287 | id: "google-chat", | ||
| 288 | name: "Google Chat", | ||
| 289 | summary: "Spaces, messages, members, reactions, and chat workflows.", | ||
| 290 | service: "chat", | ||
| 291 | version: "v1", | ||
| 292 | discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1", | ||
| 293 | }), | ||
| 294 | googleDiscoveryPreset({ | ||
| 295 | id: "google-keep", | ||
| 296 | name: "Google Keep", | ||
| 297 | summary: "Notes, lists, attachments, and collaborative annotations.", | ||
| 298 | service: "keep", | ||
| 299 | version: "v1", | ||
| 300 | discoveryUrl: "https://keep.googleapis.com/$discovery/rest?version=v1", | ||
| 301 | }), | ||
| 302 | googleDiscoveryPreset({ | ||
| 303 | id: "google-classroom", | ||
| 304 | name: "Google Classroom", | ||
| 305 | summary: "Courses, rosters, coursework, submissions, and grading data.", | ||
| 306 | service: "classroom", | ||
| 307 | version: "v1", | ||
| 308 | discoveryUrl: "https://classroom.googleapis.com/$discovery/rest?version=v1", | ||
| 309 | }), | ||
| 310 | googleDiscoveryPreset({ | ||
| 311 | id: "google-admin-directory", | ||
| 312 | name: "Google Admin Directory", | ||
| 313 | summary: "Users, groups, org units, roles, and domain directory resources.", | ||
| 314 | service: "admin", | ||
| 315 | version: "directory_v1", | ||
| 316 | discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=directory_v1", | ||
| 317 | }), | ||
| 318 | googleDiscoveryPreset({ | ||
| 319 | id: "google-admin-reports", | ||
| 320 | name: "Google Admin Reports", | ||
| 321 | summary: "Audit events, usage reports, and admin activity logs.", | ||
| 322 | service: "admin", | ||
| 323 | version: "reports_v1", | ||
| 324 | discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=reports_v1", | ||
| 325 | }), | ||
| 326 | googleDiscoveryPreset({ | ||
| 327 | id: "google-apps-script", | ||
| 328 | name: "Google Apps Script", | ||
| 329 | summary: "Projects, deployments, script execution, and Apps Script metadata.", | ||
| 330 | service: "script", | ||
| 331 | version: "v1", | ||
| 332 | discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1", | ||
| 333 | }), | ||
| 334 | googleDiscoveryPreset({ | ||
| 335 | id: "google-bigquery", | ||
| 336 | name: "Google BigQuery", | ||
| 337 | summary: "Datasets, tables, jobs, routines, and analytical query workflows.", | ||
| 338 | service: "bigquery", | ||
| 339 | version: "v2", | ||
| 340 | discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2", | ||
| 341 | }), | ||
| 342 | googleDiscoveryPreset({ | ||
| 343 | id: "google-cloud-resource-manager", | ||
| 344 | name: "Google Cloud Resource Manager", | ||
| 345 | summary: "Projects, folders, organizations, and IAM-oriented resource hierarchy.", | ||
| 346 | service: "cloudresourcemanager", | ||
| 347 | version: "v3", | ||
| 348 | discoveryUrl: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3", | ||
| 349 | }), | ||
| 350 | googleDiscoveryPreset({ | ||
| 351 | id: "google-youtube-data", | ||
| 352 | name: "YouTube Data", | ||
| 353 | summary: "Channels, playlists, videos, comments, captions, and uploads.", | ||
| 354 | service: "youtube", | ||
| 355 | version: "v3", | ||
| 356 | discoveryUrl: "https://youtube.googleapis.com/$discovery/rest?version=v3", | ||
| 357 | }), | ||
| 358 | ] as const; | ||
| 359 | |||
| 360 | export const buildSourcePresetSearch = ( | ||
| 361 | preset: SourcePreset, | ||
| 362 | ): Record<string, string> => { | ||
| 363 | switch (preset.pluginKey) { | ||
| 364 | case "openapi": | ||
| 365 | return { | ||
| 366 | preset: preset.id, | ||
| 367 | presetName: preset.name, | ||
| 368 | presetBaseUrl: preset.baseUrl, | ||
| 369 | presetSpecUrl: preset.specUrl, | ||
| 370 | }; | ||
| 371 | case "graphql": | ||
| 372 | return { | ||
| 373 | preset: preset.id, | ||
| 374 | presetName: preset.name, | ||
| 375 | presetEndpoint: preset.endpoint, | ||
| 376 | }; | ||
| 377 | case "mcp": | ||
| 378 | return { | ||
| 379 | preset: preset.id, | ||
| 380 | presetName: preset.name, | ||
| 381 | ...(preset.endpoint ? { presetEndpoint: preset.endpoint } : {}), | ||
| 382 | ...(preset.transport ? { presetTransport: preset.transport } : {}), | ||
| 383 | ...(preset.command ? { presetCommand: preset.command } : {}), | ||
| 384 | ...(preset.args ? { presetArgs: JSON.stringify(preset.args) } : {}), | ||
| 385 | ...(preset.env ? { presetEnv: JSON.stringify(preset.env) } : {}), | ||
| 386 | ...(preset.cwd ? { presetCwd: preset.cwd } : {}), | ||
| 387 | }; | ||
| 388 | default: | ||
| 389 | return { | ||
| 390 | preset: preset.id, | ||
| 391 | presetName: preset.name, | ||
| 392 | presetService: preset.service, | ||
| 393 | presetVersion: preset.version, | ||
| 394 | presetDiscoveryUrl: preset.discoveryUrl, | ||
| 395 | }; | ||
| 396 | } | ||
| 397 | }; | ||
| 398 | |||
| 399 | const normalizeComparableUrl = (value: string): string => { | ||
| 400 | const trimmed = value.trim(); | ||
| 401 | try { | ||
| 402 | const url = new URL(trimmed); | ||
| 403 | if (url.pathname.length > 1 && url.pathname.endsWith("/")) { | ||
| 404 | url.pathname = url.pathname.slice(0, -1); | ||
| 405 | } | ||
| 406 | return url.toString(); | ||
| 407 | } catch { | ||
| 408 | return trimmed; | ||
| 409 | } | ||
| 410 | }; | ||
| 411 | |||
| 412 | const tryParseUrl = (value: string): URL | null => { | ||
| 413 | try { | ||
| 414 | return new URL(value.trim()); | ||
| 415 | } catch { | ||
| 416 | return null; | ||
| 417 | } | ||
| 418 | }; | ||
| 419 | |||
| 420 | const sourceNameFromUrl = (url: URL, fallback: string): string => { | ||
| 421 | const hostname = url.hostname.replace(/^www\./, ""); | ||
| 422 | if (hostname.length === 0) { | ||
| 423 | return fallback; | ||
| 424 | } | ||
| 425 | |||
| 426 | const head = hostname.split(".")[0]?.trim(); | ||
| 427 | if (!head) { | ||
| 428 | return fallback; | ||
| 429 | } | ||
| 430 | |||
| 431 | return `${head.charAt(0).toUpperCase()}${head.slice(1)} ${fallback}`; | ||
| 432 | }; | ||
| 433 | |||
| 434 | const parseGoogleDiscoveryUrl = (value: string): { | ||
| 435 | service: string; | ||
| 436 | version: string; | ||
| 437 | discoveryUrl: string; | ||
| 438 | } | null => { | ||
| 439 | const url = tryParseUrl(value); | ||
| 440 | if (!url) { | ||
| 441 | return null; | ||
| 442 | } | ||
| 443 | |||
| 444 | const byDirectory = url.pathname.match( | ||
| 445 | /^\/discovery\/v1\/apis\/([^/]+)\/([^/]+)\/rest$/, | ||
| 446 | ); | ||
| 447 | if (byDirectory) { | ||
| 448 | return { | ||
| 449 | service: decodeURIComponent(byDirectory[1] ?? ""), | ||
| 450 | version: decodeURIComponent(byDirectory[2] ?? ""), | ||
| 451 | discoveryUrl: url.toString(), | ||
| 452 | }; | ||
| 453 | } | ||
| 454 | |||
| 455 | const versionParam = url.searchParams.get("version")?.trim(); | ||
| 456 | const isHostScopedDiscovery = | ||
| 457 | url.pathname === "/$discovery/rest" | ||
| 458 | && url.hostname.endsWith(".googleapis.com") | ||
| 459 | && url.hostname !== "www.googleapis.com"; | ||
| 460 | |||
| 461 | if (versionParam && isHostScopedDiscovery) { | ||
| 462 | const service = url.hostname.split(".")[0]?.trim(); | ||
| 463 | if (!service) { | ||
| 464 | return null; | ||
| 465 | } | ||
| 466 | |||
| 467 | return { | ||
| 468 | service, | ||
| 469 | version: versionParam, | ||
| 470 | discoveryUrl: url.toString(), | ||
| 471 | }; | ||
| 472 | } | ||
| 473 | |||
| 474 | return null; | ||
| 475 | }; | ||
| 476 | |||
| 477 | const isGraphqlUrl = (url: URL): boolean => { | ||
| 478 | const path = url.pathname.toLowerCase(); | ||
| 479 | const hostname = url.hostname.toLowerCase(); | ||
| 480 | return ( | ||
| 481 | path.endsWith("/graphql") | ||
| 482 | || path === "/graphql" | ||
| 483 | || hostname.startsWith("graphql.") | ||
| 484 | || hostname.includes("graphql") | ||
| 485 | ); | ||
| 486 | }; | ||
| 487 | |||
| 488 | const isMcpUrl = (url: URL): boolean => { | ||
| 489 | const path = url.pathname.toLowerCase(); | ||
| 490 | const hostname = url.hostname.toLowerCase(); | ||
| 491 | return path.endsWith("/mcp") || path.includes("/mcp/") || hostname.startsWith("mcp."); | ||
| 492 | }; | ||
| 493 | |||
| 494 | const isLikelyOpenApiSpecUrl = (url: URL): boolean => { | ||
| 495 | const path = url.pathname.toLowerCase(); | ||
| 496 | return ( | ||
| 497 | path.endsWith(".json") | ||
| 498 | || path.endsWith(".yaml") | ||
| 499 | || path.endsWith(".yml") | ||
| 500 | || path.includes("openapi") | ||
| 501 | || path.includes("swagger") | ||
| 502 | ); | ||
| 503 | }; | ||
| 504 | |||
| 505 | const findPresetByInput = (value: string): SourcePreset | null => { | ||
| 506 | const comparable = normalizeComparableUrl(value); | ||
| 507 | for (const preset of sourcePresets) { | ||
| 508 | if (normalizeComparableUrl(preset.previewUrl) === comparable) { | ||
| 509 | return preset; | ||
| 510 | } | ||
| 511 | |||
| 512 | if (preset.pluginKey === "openapi") { | ||
| 513 | if (normalizeComparableUrl(preset.baseUrl) === comparable) { | ||
| 514 | return preset; | ||
| 515 | } | ||
| 516 | if (normalizeComparableUrl(preset.specUrl) === comparable) { | ||
| 517 | return preset; | ||
| 518 | } | ||
| 519 | continue; | ||
| 520 | } | ||
| 521 | |||
| 522 | if (preset.pluginKey === "graphql" || preset.pluginKey === "mcp") { | ||
| 523 | if (preset.endpoint && normalizeComparableUrl(preset.endpoint) === comparable) { | ||
| 524 | return preset; | ||
| 525 | } | ||
| 526 | continue; | ||
| 527 | } | ||
| 528 | |||
| 529 | if (normalizeComparableUrl(preset.discoveryUrl) === comparable) { | ||
| 530 | return preset; | ||
| 531 | } | ||
| 532 | } | ||
| 533 | |||
| 534 | return null; | ||
| 535 | }; | ||
| 536 | |||
| 537 | export type ResolvedQuickSourceInput = { | ||
| 538 | pluginKey: SourcePreset["pluginKey"]; | ||
| 539 | search: Record<string, string>; | ||
| 540 | }; | ||
| 541 | |||
| 542 | export const resolveQuickSourceInput = ( | ||
| 543 | rawValue: string, | ||
| 544 | ): ResolvedQuickSourceInput | null => { | ||
| 545 | const trimmed = rawValue.trim(); | ||
| 546 | if (trimmed.length === 0) { | ||
| 547 | return null; | ||
| 548 | } | ||
| 549 | |||
| 550 | const commandParts = | ||
| 551 | trimmed.match(/[^\s"]+|"([^"]*)"/g)?.map((part) => | ||
| 552 | part.startsWith("\"") && part.endsWith("\"") | ||
| 553 | ? part.slice(1, -1) | ||
| 554 | : part | ||
| 555 | ) ?? null; | ||
| 556 | if (commandParts && commandParts.length > 0) { | ||
| 557 | const [command, ...args] = commandParts; | ||
| 558 | const isKnownCommand = | ||
| 559 | command === "npx" | ||
| 560 | || command === "bunx" | ||
| 561 | || command === "uvx" | ||
| 562 | || (command === "pnpm" && args[0] === "dlx"); | ||
| 563 | |||
| 564 | if (isKnownCommand) { | ||
| 565 | return { | ||
| 566 | pluginKey: "mcp", | ||
| 567 | search: { | ||
| 568 | presetName: "Local MCP", | ||
| 569 | presetTransport: "stdio", | ||
| 570 | presetCommand: command, | ||
| 571 | ...(args.length > 0 ? { presetArgs: JSON.stringify(args) } : {}), | ||
| 572 | quickInput: trimmed, | ||
| 573 | }, | ||
| 574 | }; | ||
| 575 | } | ||
| 576 | } | ||
| 577 | |||
| 578 | const preset = findPresetByInput(trimmed); | ||
| 579 | if (preset) { | ||
| 580 | return { | ||
| 581 | pluginKey: preset.pluginKey, | ||
| 582 | search: buildSourcePresetSearch(preset), | ||
| 583 | }; | ||
| 584 | } | ||
| 585 | |||
| 586 | const googleDiscovery = parseGoogleDiscoveryUrl(trimmed); | ||
| 587 | if (googleDiscovery) { | ||
| 588 | const googlePreset = sourcePresets.find( | ||
| 589 | (presetEntry) => | ||
| 590 | presetEntry.pluginKey === GOOGLE_DISCOVERY_PLUGIN_KEY | ||
| 591 | && presetEntry.service === googleDiscovery.service | ||
| 592 | && presetEntry.version === googleDiscovery.version, | ||
| 593 | ); | ||
| 594 | |||
| 595 | return { | ||
| 596 | pluginKey: GOOGLE_DISCOVERY_PLUGIN_KEY, | ||
| 597 | search: { | ||
| 598 | ...(googlePreset ? buildSourcePresetSearch(googlePreset) : {}), | ||
| 599 | presetName: | ||
| 600 | googlePreset?.name | ||
| 601 | ?? `Google ${googleDiscovery.service} ${googleDiscovery.version}`, | ||
| 602 | presetService: googleDiscovery.service, | ||
| 603 | presetVersion: googleDiscovery.version, | ||
| 604 | presetDiscoveryUrl: googleDiscovery.discoveryUrl, | ||
| 605 | quickInput: trimmed, | ||
| 606 | }, | ||
| 607 | }; | ||
| 608 | } | ||
| 609 | |||
| 610 | const url = tryParseUrl(trimmed); | ||
| 611 | if (!url) { | ||
| 612 | return null; | ||
| 613 | } | ||
| 614 | |||
| 615 | if (isGraphqlUrl(url)) { | ||
| 616 | return { | ||
| 617 | pluginKey: "graphql", | ||
| 618 | search: { | ||
| 619 | presetName: sourceNameFromUrl(url, "GraphQL"), | ||
| 620 | presetEndpoint: trimmed, | ||
| 621 | quickInput: trimmed, | ||
| 622 | }, | ||
| 623 | }; | ||
| 624 | } | ||
| 625 | |||
| 626 | if (isMcpUrl(url)) { | ||
| 627 | return { | ||
| 628 | pluginKey: "mcp", | ||
| 629 | search: { | ||
| 630 | presetName: sourceNameFromUrl(url, "MCP"), | ||
| 631 | presetEndpoint: trimmed, | ||
| 632 | presetTransport: "auto", | ||
| 633 | quickInput: trimmed, | ||
| 634 | }, | ||
| 635 | }; | ||
| 636 | } | ||
| 637 | |||
| 638 | if (isLikelyOpenApiSpecUrl(url)) { | ||
| 639 | return { | ||
| 640 | pluginKey: "openapi", | ||
| 641 | search: { | ||
| 642 | presetName: sourceNameFromUrl(url, "OpenAPI"), | ||
| 643 | presetSpecUrl: trimmed, | ||
| 644 | quickInput: trimmed, | ||
| 645 | }, | ||
| 646 | }; | ||
| 647 | } | ||
| 648 | |||
| 649 | return { | ||
| 650 | pluginKey: "openapi", | ||
| 651 | search: { | ||
| 652 | presetName: sourceNameFromUrl(url, "OpenAPI"), | ||
| 653 | presetBaseUrl: trimmed, | ||
| 654 | quickInput: trimmed, | ||
| 655 | }, | ||
| 656 | }; | ||
| 657 | }; | ||
| @@ -2,116 +2,122 @@ import { Link } from "@tanstack/react-router"; · 1 unmodified line | |||
| 2 | import { useSources } from "@executor/react"; | 2 | import { useSources } from "@executor/react"; |
| 3 | import { sourcePluginsIndexPath } from "@executor/react/plugins"; | 3 | import { sourcePluginsIndexPath } from "@executor/react/plugins"; |
| 4 | import { LoadableBlock } from "../components/loadable"; | 4 | import { LoadableBlock } from "../components/loadable"; |
| 5 | import { LocalMcpInstallCard } from "../components/local-mcp-install-card"; | ||
| 6 | import { SourceFavicon } from "../components/source-favicon"; | ||
| 5 | import { Badge } from "../components/ui/badge"; | 7 | import { Badge } from "../components/ui/badge"; |
| 6 | import { Button } from "../components/ui/button"; | 8 | import { Button } from "../components/ui/button"; |
| 7 | import { SourcePluginsResetState } from "../components/source-plugins-reset-state"; | 9 | import { IconSources, IconPlus } from "../components/icons"; |
| 8 | import { | 10 | import { |
| 9 | getSourceFrontendPaths, | 11 | getSourceFrontendPaths, |
| 10 | registeredSourceFrontendTypes, | ||
| 11 | } from "../plugins"; | 12 | } from "../plugins"; |
| 12 | 13 | ||
| 14 | const statusVariant = (status: string) => | ||
| 15 | status === "connected" | ||
| 16 | ? "default" as const | ||
| 17 | : status === "error" | ||
| 18 | ? "destructive" as const | ||
| 19 | : "muted" as const; | ||
| 20 | |||
| 13 | export function HomePage() { | 21 | export function HomePage() { |
| 14 | const sources = useSources(); | 22 | const sources = useSources(); |
| 15 | 23 | ||
| 16 | return ( | 24 | return ( |
| 17 | <div className="flex-1 overflow-y-auto"> | 25 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 18 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> | 26 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> |
| 19 | <div className="rounded-3xl border border-border bg-card p-8"> | 27 | {/* Header */} |
| 20 | <div className="inline-flex rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground"> | 28 | <div className="flex items-end justify-between mb-8"> |
| 21 | Sources | 29 | <div> |
| 30 | <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | ||
| 31 | Sources | ||
| 32 | </h1> | ||
| 33 | <p className="mt-1.5 text-[14px] text-muted-foreground"> | ||
| 34 | Connected tool providers in this workspace. | ||
| 35 | </p> | ||
| 22 | </div> | 36 | </div> |
| 23 | <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | 37 | <Link to={sourcePluginsIndexPath}> |
| 24 | Source plugins now own source-specific product behavior | 38 | <Button size="sm"> |
| 25 | </h1> | 39 | <IconPlus className="size-3.5" /> |
| 26 | <p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground"> | 40 | Add source |
| 27 | Add, edit, inspect, and authorize sources through plugin-owned UI and | 41 | </Button> |
| 28 | API surfaces. The core app stays generic while each plugin carries its | 42 | </Link> |
| 29 | own transport, auth, and storage behavior. | ||
| 30 | </p> | ||
| 31 | {registeredSourceFrontendTypes.length > 0 && ( | ||
| 32 | <div className="mt-5 flex flex-wrap gap-2"> | ||
| 33 | {registeredSourceFrontendTypes.map((definition) => ( | ||
| 34 | <Badge key={definition.key} variant="outline"> | ||
| 35 | {definition.displayName} | ||
| 36 | </Badge> | ||
| 37 | ))} | ||
| 38 | </div> | ||
| 39 | )} | ||
| 40 | </div> | 43 | </div> |
| 41 | 44 | ||
| 42 | <div className="mt-8"> | 45 | <LocalMcpInstallCard className="mb-8" /> |
| 43 | <LoadableBlock loadable={sources} loading="Loading sources..."> | 46 | |
| 44 | {(items) => | 47 | {/* Source list */} |
| 45 | !Array.isArray(items) ? ( | 48 | <LoadableBlock loadable={sources} loading="Loading sources..."> |
| 46 | <SourcePluginsResetState | 49 | {(items) => |
| 47 | title="Unexpected sources payload" | 50 | !Array.isArray(items) ? ( |
| 48 | message="The sources list returned data in an unexpected shape." | 51 | <div className="rounded-xl border border-destructive/20 bg-destructive/5 px-5 py-4 text-sm text-destructive"> |
| 49 | /> | 52 | Sources returned an unexpected payload. |
| 50 | ) : items.length === 0 ? ( | 53 | </div> |
| 51 | <SourcePluginsResetState | 54 | ) : items.length === 0 ? ( |
| 52 | title="No sources connected yet" | 55 | <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20"> |
| 53 | message="Use the add flow to connect an OpenAPI, GraphQL, MCP, or Google Discovery source through its plugin-owned surface." | 56 | <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4"> |
| 54 | action={( | 57 | <IconSources className="size-5" /> |
| 55 | <Link to={sourcePluginsIndexPath}> | 58 | </div> |
| 56 | <Button size="sm" variant="outline"> | 59 | <p className="text-[14px] font-medium text-foreground/70 mb-1"> |
| 57 | Add Source | 60 | No sources yet |
| 58 | </Button> | 61 | </p> |
| 59 | </Link> | 62 | <p className="text-[13px] text-muted-foreground/60 mb-5"> |
| 60 | )} | 63 | Add a source to get started. |
| 61 | /> | 64 | </p> |
| 62 | ) : ( | 65 | <Link to={sourcePluginsIndexPath}> |
| 63 | <div className="grid gap-3"> | 66 | <Button size="sm"> |
| 64 | {items.map((source) => { | 67 | <IconPlus className="size-3.5" /> |
| 65 | const paths = getSourceFrontendPaths(source.kind); | 68 | Add source |
| 66 | const card = ( | 69 | </Button> |
| 67 | <div className="rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90"> | 70 | </Link> |
| 68 | <div className="flex items-start justify-between gap-3"> | 71 | </div> |
| 69 | <div> | 72 | ) : ( |
| 70 | <div className="text-sm font-semibold text-foreground"> | 73 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> |
| 74 | {items.map((source) => { | ||
| 75 | const paths = getSourceFrontendPaths(source.kind); | ||
| 76 | const card = ( | ||
| 77 | <div className="flex h-full flex-col rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90"> | ||
| 78 | <div className="flex items-start gap-3"> | ||
| 79 | <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> | ||
| 80 | <SourceFavicon source={source} className="size-4" /> | ||
| 81 | </div> | ||
| 82 | <div className="min-w-0 flex-1"> | ||
| 83 | <div className="flex items-start justify-between gap-2"> | ||
| 84 | <div className="truncate text-sm font-semibold text-foreground"> | ||
| 71 | {source.name} | 85 | {source.name} |
| 72 | </div> | 86 | </div> |
| 73 | <div className="mt-1 text-xs text-muted-foreground"> | 87 | <Badge variant={statusVariant(source.status)} className="shrink-0"> |
| 74 | {source.kind} | 88 | {source.status} |
| 75 | </div> | 89 | </Badge> |
| 90 | </div> | ||
| 91 | <div className="mt-0.5 text-xs text-muted-foreground"> | ||
| 92 | {source.kind} | ||
| 76 | </div> | 93 | </div> |
| 77 | <Badge | ||
| 78 | variant={ | ||
| 79 | source.status === "connected" | ||
| 80 | ? "default" | ||
| 81 | : source.status === "error" | ||
| 82 | ? "destructive" | ||
| 83 | : "muted" | ||
| 84 | } | ||
| 85 | > | ||
| 86 | {source.status} | ||
| 87 | </Badge> | ||
| 88 | </div> | 94 | </div> |
| 89 | </div> | 95 | </div> |
| 90 | ); | 96 | </div> |
| 91 | 97 | ); | |
| 92 | if (!paths) { | ||
| 93 | return ( | ||
| 94 | <div key={source.id}> | ||
| 95 | {card} | ||
| 96 | </div> | ||
| 97 | ); | ||
| 98 | } | ||
| 99 | 98 | ||
| 99 | if (!paths) { | ||
| 100 | return ( | 100 | return ( |
| 101 | <Link | 101 | <div key={source.id}> |
| 102 | key={source.id} | ||
| 103 | to={paths.detail(source.id)} | ||
| 104 | search={{ tab: "model" }} | ||
| 105 | > | ||
| 106 | {card} | 102 | {card} |
| 107 | </Link> | 103 | </div> |
| 108 | ); | 104 | ); |
| 109 | })} | 105 | } |
| 110 | </div> | 106 | |
| 111 | ) | 107 | return ( |
| 112 | } | 108 | <Link |
| 113 | </LoadableBlock> | 109 | key={source.id} |
| 114 | </div> | 110 | to={paths.detail(source.id)} |
| 111 | search={{ tab: "model" }} | ||
| 112 | > | ||
| 113 | {card} | ||
| 114 | </Link> | ||
| 115 | ); | ||
| 116 | })} | ||
| 117 | </div> | ||
| 118 | ) | ||
| 119 | } | ||
| 120 | </LoadableBlock> | ||
| 115 | </div> | 121 | </div> |
| 116 | </div> | 122 | </div> |
| 117 | ); | 123 | ); |
| @@ -42,7 +42,7 @@ export function SecretsPage() { · 41 unmodified lines | |||
| 42 | : null; | 42 | : null; |
| 43 | 43 | ||
| 44 | return ( | 44 | return ( |
| 45 | <div className="flex-1 overflow-y-auto"> | 45 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 46 | <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14"> | 46 | <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14"> |
| 47 | {/* Header */} | 47 | {/* Header */} |
| 48 | <div className="flex items-end justify-between mb-8"> | 48 | <div className="flex items-end justify-between mb-8"> |
| @@ -259,19 +259,16 @@ function SecretRow(props: { · 210 unmodified lines | |||
| 259 | const { secret, providerLabel, isEditing } = props; | 259 | const { secret, providerLabel, isEditing } = props; |
| 260 | const deleteSecret = useDeleteSecret(); | 260 | const deleteSecret = useDeleteSecret(); |
| 261 | const [isDeleting, setIsDeleting] = useState(false); | 261 | const [isDeleting, setIsDeleting] = useState(false); |
| 262 | const [confirmDelete, setConfirmDelete] = useState(false); | ||
| 262 | 263 | ||
| 263 | const handleDelete = async () => { | 264 | const handleDelete = async () => { |
| 264 | const confirmed = window.confirm( | ||
| 265 | `Delete "${secret.name || secret.id}"? This cannot be undone. Sources using this secret will lose access.`, | ||
| 266 | ); | ||
| 267 | if (!confirmed) return; | ||
| 268 | |||
| 269 | setIsDeleting(true); | 265 | setIsDeleting(true); |
| 270 | try { | 266 | try { |
| 271 | await deleteSecret.mutateAsync(secret.id); | 267 | await deleteSecret.mutateAsync(secret.id); |
| 272 | } catch { | 268 | } catch { |
| 273 | // refresh will show the secret still there | 269 | // refresh will show the secret still there |
| 274 | } finally { | 270 | } finally { |
| 271 | setConfirmDelete(false); | ||
| 275 | setIsDeleting(false); | 272 | setIsDeleting(false); |
| 276 | } | 273 | } |
| 277 | }; | 274 | }; |
| @@ -338,15 +335,42 @@ function SecretRow(props: { · 60 unmodified lines | |||
| 338 | <IconPencil className="size-3" /> | 335 | <IconPencil className="size-3" /> |
| 339 | Edit | 336 | Edit |
| 340 | </button> | 337 | </button> |
| 341 | <button | 338 | {confirmDelete ? ( |
| 342 | type="button" | 339 | <> |
| 343 | onClick={handleDelete} | 340 | <span className="text-[10px] font-medium text-destructive"> |
| 344 | disabled={isDeleting} | 341 | Confirm delete? |
| 345 | className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/8 disabled:opacity-50" | 342 | </span> |
| 346 | > | 343 | <button |
| 347 | {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />} | 344 | type="button" |
| 348 | Delete | 345 | onClick={() => setConfirmDelete(false)} |
| 349 | </button> | 346 | disabled={isDeleting} |
| 347 | className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50" | ||
| 348 | > | ||
| 349 | Cancel | ||
| 350 | </button> | ||
| 351 | <button | ||
| 352 | type="button" | ||
| 353 | onClick={() => { | ||
| 354 | void handleDelete(); | ||
| 355 | }} | ||
| 356 | disabled={isDeleting} | ||
| 357 | className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/8 disabled:opacity-50" | ||
| 358 | > | ||
| 359 | {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />} | ||
| 360 | {isDeleting ? "Deleting..." : "Delete"} | ||
| 361 | </button> | ||
| 362 | </> | ||
| 363 | ) : ( | ||
| 364 | <button | ||
| 365 | type="button" | ||
| 366 | onClick={() => setConfirmDelete(true)} | ||
| 367 | disabled={isDeleting} | ||
| 368 | className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/8 disabled:opacity-50" | ||
| 369 | > | ||
| 370 | {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />} | ||
| 371 | Delete | ||
| 372 | </button> | ||
| 373 | )} | ||
| 350 | </div> | 374 | </div> |
| 351 | </div> | 375 | </div> |
| 352 | 376 | ||
| @@ -1,10 +1,16 @@ | |||
| 1 | { | 1 | { |
| 2 | "$schema": "https://opencode.ai/config.json", | ||
| 3 | "mcp": { | 2 | "mcp": { |
| 4 | "executor": { | 3 | "executor-stdio": { |
| 5 | "type": "remote", | 4 | "type": "local", |
| 6 | "url": "http://localhost:8788/mcp", | 5 | "command": [ |
| 7 | "enabled": true | 6 | "bun", |
| 7 | "run", | ||
| 8 | "executor", | ||
| 9 | "mcp", | ||
| 10 | "--stdio" | ||
| 11 | ], | ||
| 12 | "enabled": true, | ||
| 13 | "environment": {} | ||
| 8 | } | 14 | } |
| 9 | } | 15 | } |
| 10 | } | 16 | } |
| @@ -47,6 +47,16 @@ import { · 46 unmodified lines | |||
| 47 | import { | 47 | import { |
| 48 | RuntimeSourceStoreService, | 48 | RuntimeSourceStoreService, |
| 49 | } from "../../sources/source-store"; | 49 | } from "../../sources/source-store"; |
| 50 | import { | ||
| 51 | SecretMaterialDeleterService, | ||
| 52 | SecretMaterialResolverService, | ||
| 53 | SecretMaterialStorerService, | ||
| 54 | SecretMaterialUpdaterService, | ||
| 55 | type DeleteSecretMaterial, | ||
| 56 | type ResolveSecretMaterial, | ||
| 57 | type StoreSecretMaterial, | ||
| 58 | type UpdateSecretMaterial, | ||
| 59 | } from "../../scope/secret-material-providers"; | ||
| 50 | export { | 60 | export { |
| 51 | createCodeExecutorForRuntime, | 61 | createCodeExecutorForRuntime, |
| 52 | resolveConfiguredExecutionRuntime, | 62 | resolveConfiguredExecutionRuntime, |
| @@ -74,6 +84,12 @@ export const createScopeExecutionEnvironmentResolver = · 21 unmodified lines | |||
| 74 | scopeConfigStore: ScopeConfigStoreShape; | 84 | scopeConfigStore: ScopeConfigStoreShape; |
| 75 | scopeStateStore: ScopeStateStoreShape; | 85 | scopeStateStore: ScopeStateStoreShape; |
| 76 | sourceArtifactStore: SourceArtifactStoreShape; | 86 | sourceArtifactStore: SourceArtifactStoreShape; |
| 87 | secretMaterialServices: { | ||
| 88 | resolve: ResolveSecretMaterial; | ||
| 89 | store: StoreSecretMaterial; | ||
| 90 | delete: DeleteSecretMaterial; | ||
| 91 | update: UpdateSecretMaterial; | ||
| 92 | }; | ||
| 77 | }): ResolveExecutionEnvironment => | 93 | }): ResolveExecutionEnvironment => |
| 78 | ({ scopeId, actorScopeId, onElicitation }) => | 94 | ({ scopeId, actorScopeId, onElicitation }) => |
| 79 | Effect.gen(function* () { | 95 | Effect.gen(function* () { |
| @@ -99,6 +115,7 @@ export const createScopeExecutionEnvironmentResolver = · 19 unmodified lines | |||
| 99 | sourceArtifactStore: input.sourceArtifactStore, | 115 | sourceArtifactStore: input.sourceArtifactStore, |
| 100 | runtimeLocalScope, | 116 | runtimeLocalScope, |
| 101 | localToolRuntime, | 117 | localToolRuntime, |
| 118 | secretMaterialServices: input.secretMaterialServices, | ||
| 102 | onElicitation, | 119 | onElicitation, |
| 103 | }); | 120 | }); |
| 104 | 121 | ||
| @@ -140,6 +157,10 @@ export const RuntimeExecutionResolverLive = ( · 35 unmodified lines | |||
| 140 | const scopeConfigStore = yield* ScopeConfigStore; | 157 | const scopeConfigStore = yield* ScopeConfigStore; |
| 141 | const scopeStateStore = yield* ScopeStateStore; | 158 | const scopeStateStore = yield* ScopeStateStore; |
| 142 | const sourceArtifactStore = yield* SourceArtifactStore; | 159 | const sourceArtifactStore = yield* SourceArtifactStore; |
| 160 | const resolveSecretMaterial = yield* SecretMaterialResolverService; | ||
| 161 | const storeSecretMaterial = yield* SecretMaterialStorerService; | ||
| 162 | const deleteSecretMaterial = yield* SecretMaterialDeleterService; | ||
| 163 | const updateSecretMaterial = yield* SecretMaterialUpdaterService; | ||
| 143 | 164 | ||
| 144 | return createScopeExecutionEnvironmentResolver({ | 165 | return createScopeExecutionEnvironmentResolver({ |
| 145 | executorStateStore, | 166 | executorStateStore, |
| @@ -151,6 +172,12 @@ export const RuntimeExecutionResolverLive = ( · 5 unmodified lines | |||
| 151 | scopeConfigStore, | 172 | scopeConfigStore, |
| 152 | scopeStateStore, | 173 | scopeStateStore, |
| 153 | sourceArtifactStore, | 174 | sourceArtifactStore, |
| 175 | secretMaterialServices: { | ||
| 176 | resolve: resolveSecretMaterial, | ||
| 177 | store: storeSecretMaterial, | ||
| 178 | delete: deleteSecretMaterial, | ||
| 179 | update: updateSecretMaterial, | ||
| 180 | }, | ||
| 154 | }); | 181 | }); |
| 155 | }), | 182 | }), |
| 156 | ); | 183 | ); |
| @@ -17,6 +17,16 @@ import * as Effect from "effect/Effect"; · 16 unmodified lines | |||
| 17 | import { | 17 | import { |
| 18 | RuntimeSourceCatalogStoreService, | 18 | RuntimeSourceCatalogStoreService, |
| 19 | } from "../../catalog/source/runtime"; | 19 | } from "../../catalog/source/runtime"; |
| 20 | import { | ||
| 21 | SecretMaterialDeleterService, | ||
| 22 | SecretMaterialResolverService, | ||
| 23 | SecretMaterialStorerService, | ||
| 24 | SecretMaterialUpdaterService, | ||
| 25 | type DeleteSecretMaterial, | ||
| 26 | type ResolveSecretMaterial, | ||
| 27 | type StoreSecretMaterial, | ||
| 28 | type UpdateSecretMaterial, | ||
| 29 | } from "../../scope/secret-material-providers"; | ||
| 20 | import type { | 30 | import type { |
| 21 | RuntimeLocalScopeState, | 31 | RuntimeLocalScopeState, |
| 22 | } from "../../scope/runtime-context"; | 32 | } from "../../scope/runtime-context"; |
| @@ -59,6 +69,7 @@ import { · 36 unmodified lines | |||
| 59 | import { | 69 | import { |
| 60 | runtimeEffectError, | 70 | runtimeEffectError, |
| 61 | } from "../../effect-errors"; | 71 | } from "../../effect-errors"; |
| 72 | import * as Layer from "effect/Layer"; | ||
| 62 | 73 | ||
| 63 | export const createScopeToolInvoker = (input: { | 74 | export const createScopeToolInvoker = (input: { |
| 64 | scopeId: Source["scopeId"]; | 75 | scopeId: Source["scopeId"]; |
| @@ -77,6 +88,12 @@ export const createScopeToolInvoker = (input: { · 12 unmodified lines | |||
| 77 | sourceArtifactStore: SourceArtifactStoreShape; | 88 | sourceArtifactStore: SourceArtifactStoreShape; |
| 78 | runtimeLocalScope: RuntimeLocalScopeState | null; | 89 | runtimeLocalScope: RuntimeLocalScopeState | null; |
| 79 | localToolRuntime: LocalToolRuntime; | 90 | localToolRuntime: LocalToolRuntime; |
| 91 | secretMaterialServices: { | ||
| 92 | resolve: ResolveSecretMaterial; | ||
| 93 | store: StoreSecretMaterial; | ||
| 94 | delete: DeleteSecretMaterial; | ||
| 95 | update: UpdateSecretMaterial; | ||
| 96 | }; | ||
| 80 | onElicitation?: Parameters< | 97 | onElicitation?: Parameters< |
| 81 | typeof makeToolInvokerFromTools | 98 | typeof makeToolInvokerFromTools |
| 82 | >[0]["onElicitation"]; | 99 | >[0]["onElicitation"]; |
| @@ -89,8 +106,17 @@ export const createScopeToolInvoker = (input: { · 6 unmodified lines | |||
| 89 | scopeStateStore: input.scopeStateStore, | 106 | scopeStateStore: input.scopeStateStore, |
| 90 | sourceArtifactStore: input.sourceArtifactStore, | 107 | sourceArtifactStore: input.sourceArtifactStore, |
| 91 | }); | 108 | }); |
| 109 | const secretMaterialLayer = Layer.mergeAll( | ||
| 110 | Layer.succeed(SecretMaterialResolverService, input.secretMaterialServices.resolve), | ||
| 111 | Layer.succeed(SecretMaterialStorerService, input.secretMaterialServices.store), | ||
| 112 | Layer.succeed(SecretMaterialDeleterService, input.secretMaterialServices.delete), | ||
| 113 | Layer.succeed(SecretMaterialUpdaterService, input.secretMaterialServices.update), | ||
| 114 | ); | ||
| 92 | const provideWorkspaceStorage = <A, E, R>(effect: Effect.Effect<A, E, R>) => | 115 | const provideWorkspaceStorage = <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 93 | effect.pipe(Effect.provide(scopeStorageLayer)); | 116 | effect.pipe(Effect.provide(scopeStorageLayer)); |
| 117 | const provideSecretMaterialServices = <A, E, R>( | ||
| 118 | effect: Effect.Effect<A, E, R>, | ||
| 119 | ) => effect.pipe(Effect.provide(secretMaterialLayer)); | ||
| 94 | 120 | ||
| 95 | const executorTools = createExecutorToolMap({ | 121 | const executorTools = createExecutorToolMap({ |
| 96 | scopeId: input.scopeId, | 122 | scopeId: input.scopeId, |
| @@ -146,7 +172,8 @@ export const createScopeToolInvoker = (input: { · 49 unmodified lines | |||
| 146 | context?: Record<string, unknown>; | 172 | context?: Record<string, unknown>; |
| 147 | }) => | 173 | }) => |
| 148 | provideRuntimeLocalScope( | 174 | provideRuntimeLocalScope( |
| 149 | provideWorkspaceStorage( | 175 | provideSecretMaterialServices( |
| 176 | provideWorkspaceStorage( | ||
| 150 | Effect.gen(function* () { | 177 | Effect.gen(function* () { |
| 151 | const catalogTool = yield* loadWorkspaceCatalogToolByPath({ | 178 | const catalogTool = yield* loadWorkspaceCatalogToolByPath({ |
| 152 | scopeId: input.scopeId, | 179 | scopeId: input.scopeId, |
| @@ -179,6 +206,7 @@ export const createScopeToolInvoker = (input: { · 26 unmodified lines | |||
| 179 | context: invocation.context, | 206 | context: invocation.context, |
| 180 | }); | 207 | }); |
| 181 | }), | 208 | }), |
| 209 | ), | ||
| 182 | ), | 210 | ), |
| 183 | input.runtimeLocalScope, | 211 | input.runtimeLocalScope, |
| 184 | ); | 212 | ); |
| @@ -25,6 +25,7 @@ import * as Effect from "effect/Effect"; · 24 unmodified lines | |||
| 25 | import * as Schema from "effect/Schema"; | 25 | import * as Schema from "effect/Schema"; |
| 26 | 26 | ||
| 27 | import { | 27 | import { |
| 28 | GoogleDiscoveryStartBatchOAuthInputSchema, | ||
| 28 | GOOGLE_DISCOVERY_EXECUTOR_KEY, | 29 | GOOGLE_DISCOVERY_EXECUTOR_KEY, |
| 29 | GOOGLE_DISCOVERY_OAUTH_STORAGE_PREFIX, | 30 | GOOGLE_DISCOVERY_OAUTH_STORAGE_PREFIX, |
| 30 | GOOGLE_DISCOVERY_PLUGIN_KEY, | 31 | GOOGLE_DISCOVERY_PLUGIN_KEY, |
| @@ -36,6 +37,7 @@ import { · 5 unmodified lines | |||
| 36 | type GoogleDiscoveryConnectInput, | 37 | type GoogleDiscoveryConnectInput, |
| 37 | type GoogleDiscoveryOAuthPopupResult, | 38 | type GoogleDiscoveryOAuthPopupResult, |
| 38 | type GoogleDiscoverySourceConfigPayload, | 39 | type GoogleDiscoverySourceConfigPayload, |
| 40 | type GoogleDiscoveryStartBatchOAuthInput, | ||
| 39 | type GoogleDiscoveryStartOAuthInput, | 41 | type GoogleDiscoveryStartOAuthInput, |
| 40 | type GoogleDiscoveryStartOAuthResult, | 42 | type GoogleDiscoveryStartOAuthResult, |
| 41 | type GoogleDiscoveryUpdateSourceInput, | 43 | type GoogleDiscoveryUpdateSourceInput, |
| @@ -58,6 +60,9 @@ type GoogleDiscoveryExecutorExtension = { · 16 unmodified lines | |||
| 58 | startOAuth: ( | 60 | startOAuth: ( |
| 59 | input: GoogleDiscoveryStartOAuthInput, | 61 | input: GoogleDiscoveryStartOAuthInput, |
| 60 | ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>; | 62 | ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>; |
| 63 | startBatchOAuth: ( | ||
| 64 | input: GoogleDiscoveryStartBatchOAuthInput, | ||
| 65 | ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>; | ||
| 61 | completeOAuth: (input: { | 66 | completeOAuth: (input: { |
| 62 | state: string; | 67 | state: string; |
| 63 | code?: string; | 68 | code?: string; |
| @@ -122,6 +127,14 @@ export const GoogleDiscoveryHttpGroup = HttpApiGroup.make(GOOGLE_DISCOVERY_PLUGI · 58 unmodified lines | |||
| 122 | .addError(ControlPlaneForbiddenError) | 127 | .addError(ControlPlaneForbiddenError) |
| 123 | .addError(ControlPlaneStorageError), | 128 | .addError(ControlPlaneStorageError), |
| 124 | ) | 129 | ) |
| 130 | .add( | ||
| 131 | HttpApiEndpoint.post("startBatchOAuth")`/workspaces/${workspaceIdParam}/plugins/google-discovery/oauth/start-batch` | ||
| 132 | .setPayload(GoogleDiscoveryStartBatchOAuthInputSchema) | ||
| 133 | .addSuccess(GoogleDiscoveryStartOAuthResultSchema) | ||
| 134 | .addError(ControlPlaneBadRequestError) | ||
| 135 | .addError(ControlPlaneForbiddenError) | ||
| 136 | .addError(ControlPlaneStorageError), | ||
| 137 | ) | ||
| 125 | .add( | 138 | .add( |
| 126 | HttpApiEndpoint.get("oauthCallback")`/plugins/google-discovery/oauth/callback` | 139 | HttpApiEndpoint.get("oauthCallback")`/plugins/google-discovery/oauth/callback` |
| 127 | .setUrlParams(callbackParamsSchema) | 140 | .setUrlParams(callbackParamsSchema) |
| @@ -181,10 +194,13 @@ const popupDocument = (payload: GoogleDiscoveryOAuthPopupResult): string => { · 53 unmodified lines | |||
| 181 | .replaceAll("<", "\\u003c") | 194 | .replaceAll("<", "\\u003c") |
| 182 | .replaceAll(">", "\\u003e") | 195 | .replaceAll(">", "\\u003e") |
| 183 | .replaceAll("&", "\\u0026"); | 196 | .replaceAll("&", "\\u0026"); |
| 184 | const title = payload.ok ? "Google OAuth connected" : "Google OAuth failed"; | 197 | const title = payload.ok ? "Connected" : "Connection failed"; |
| 185 | const message = payload.ok | 198 | const message = payload.ok |
| 186 | ? "Google credentials are ready. Return to the source form to finish saving." | 199 | ? payload.mode === "batch" |
| 200 | ? `Connected ${payload.sources.length} Google API${payload.sources.length === 1 ? "" : "s"}. This window will close automatically.` | ||
| 201 | : "Google account connected. This window will close automatically." | ||
| 187 | : payload.error; | 202 | : payload.error; |
| 203 | const statusColor = payload.ok ? "#22c55e" : "#ef4444"; | ||
| 188 | 204 | ||
| 189 | return `<!doctype html> | 205 | return `<!doctype html> |
| 190 | <html lang="en"> | 206 | <html lang="en"> |
| @@ -193,10 +209,17 @@ const popupDocument = (payload: GoogleDiscoveryOAuthPopupResult): string => { · 2 unmodified lines | |||
| 193 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | 209 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 194 | <title>${escapeHtml(title)}</title> | 210 | <title>${escapeHtml(title)}</title> |
| 195 | </head> | 211 | </head> |
| 196 | <body> | 212 | <body style="font-family: system-ui, -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #fafafa;"> |
| 197 | <main> | 213 | <main style="text-align: center; max-width: 360px; padding: 24px;"> |
| 198 | <h1>${escapeHtml(title)}</h1> | 214 | <div style="width: 40px; height: 40px; border-radius: 50%; background: ${statusColor}; margin: 0 auto 16px; display: flex; align-items: center; justify-content: center;"> |
| 199 | <p>${escapeHtml(message)}</p> | 215 | <svg width="20" height="20" viewBox="0 0 20 20" fill="none"> |
| 216 | ${payload.ok | ||
| 217 | ? '<path d="M6 10l3 3 5-6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' | ||
| 218 | : '<path d="M7 7l6 6M13 7l-6 6" stroke="white" stroke-width="2" stroke-linecap="round"/>'} | ||
| 219 | </svg> | ||
| 220 | </div> | ||
| 221 | <h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 600; color: #111;">${escapeHtml(title)}</h1> | ||
| 222 | <p style="margin: 0; font-size: 14px; color: #666; line-height: 1.5;">${escapeHtml(message)}</p> | ||
| 200 | </main> | 223 | </main> |
| 201 | <script> | 224 | <script> |
| 202 | (() => { | 225 | (() => { |
| @@ -299,6 +322,19 @@ export const googleDiscoveryHttpPlugin = (): ExecutorHttpPlugin< · 96 unmodified lines | |||
| 299 | ), | 322 | ), |
| 300 | ) | 323 | ) |
| 301 | ) | 324 | ) |
| 325 | .handle("startBatchOAuth", ({ path, payload }) => | ||
| 326 | resolveRequestedLocalWorkspace( | ||
| 327 | "googleDiscovery.startBatchOAuth", | ||
| 328 | path.workspaceId, | ||
| 329 | ).pipe( | ||
| 330 | Effect.flatMap(() => | ||
| 331 | executor[GOOGLE_DISCOVERY_EXECUTOR_KEY].startBatchOAuth(payload) | ||
| 332 | ), | ||
| 333 | Effect.mapError((cause) => | ||
| 334 | toStorageError("googleDiscovery.startBatchOAuth", cause) | ||
| 335 | ), | ||
| 336 | ) | ||
| 337 | ) | ||
| 302 | .handle("oauthCallback", ({ urlParams }) => | 338 | .handle("oauthCallback", ({ urlParams }) => |
| 303 | executor[GOOGLE_DISCOVERY_EXECUTOR_KEY].completeOAuth({ | 339 | executor[GOOGLE_DISCOVERY_EXECUTOR_KEY].completeOAuth({ |
| 304 | state: urlParams.state, | 340 | state: urlParams.state, |
| @@ -5,6 +5,7 @@ import { · 4 unmodified lines | |||
| 5 | Result, | 5 | Result, |
| 6 | useAtomSet, | 6 | useAtomSet, |
| 7 | useAtomValue, | 7 | useAtomValue, |
| 8 | useCreateSecret, | ||
| 8 | useExecutorMutation, | 9 | useExecutorMutation, |
| 9 | useLocalInstallation, | 10 | useLocalInstallation, |
| 10 | useSecrets, | 11 | useSecrets, |
| @@ -29,9 +30,11 @@ import { · 18 unmodified lines | |||
| 29 | GOOGLE_DISCOVERY_PLUGIN_KEY, | 30 | GOOGLE_DISCOVERY_PLUGIN_KEY, |
| 30 | GOOGLE_DISCOVERY_SOURCE_KIND, | 31 | GOOGLE_DISCOVERY_SOURCE_KIND, |
| 31 | defaultGoogleDiscoveryUrl, | 32 | defaultGoogleDiscoveryUrl, |
| 33 | type GoogleDiscoveryBatchSourceInput, | ||
| 32 | type GoogleDiscoveryConnectInput, | 34 | type GoogleDiscoveryConnectInput, |
| 33 | type GoogleDiscoveryConnectionAuth, | 35 | type GoogleDiscoveryConnectionAuth, |
| 34 | type GoogleDiscoveryOAuthPopupResult, | 36 | type GoogleDiscoveryOAuthPopupResult, |
| 37 | type GoogleDiscoveryStartBatchOAuthInput, | ||
| 35 | type GoogleDiscoveryStartOAuthInput, | 38 | type GoogleDiscoveryStartOAuthInput, |
| 36 | } from "@executor/plugin-google-discovery-shared"; | 39 | } from "@executor/plugin-google-discovery-shared"; |
| 37 | 40 | ||
| @@ -94,6 +97,266 @@ const defaultGoogleInput = (): GoogleDiscoveryConnectInput => ({ · 56 unmodified lines | |||
| 94 | }, | 97 | }, |
| 95 | }); | 98 | }); |
| 96 | 99 | ||
| 100 | type GoogleDiscoveryBatchTemplate = { | ||
| 101 | id: string; | ||
| 102 | name: string; | ||
| 103 | summary: string; | ||
| 104 | service: string; | ||
| 105 | version: string; | ||
| 106 | discoveryUrl: string; | ||
| 107 | }; | ||
| 108 | |||
| 109 | const googleDiscoveryTemplate = ( | ||
| 110 | input: GoogleDiscoveryBatchTemplate, | ||
| 111 | ): GoogleDiscoveryBatchTemplate => ({ | ||
| 112 | ...input, | ||
| 113 | discoveryUrl: input.discoveryUrl || defaultGoogleDiscoveryUrl(input.service, input.version), | ||
| 114 | }); | ||
| 115 | |||
| 116 | const GOOGLE_DISCOVERY_BATCH_TEMPLATES: ReadonlyArray<GoogleDiscoveryBatchTemplate> = [ | ||
| 117 | googleDiscoveryTemplate({ | ||
| 118 | id: "google-calendar", | ||
| 119 | name: "Google Calendar", | ||
| 120 | summary: "Calendars, events, ACLs, and scheduling workflows.", | ||
| 121 | service: "calendar", | ||
| 122 | version: "v3", | ||
| 123 | discoveryUrl: "https://calendar-json.googleapis.com/$discovery/rest?version=v3", | ||
| 124 | }), | ||
| 125 | googleDiscoveryTemplate({ | ||
| 126 | id: "google-drive", | ||
| 127 | name: "Google Drive", | ||
| 128 | summary: "Files, folders, permissions, comments, and shared drives.", | ||
| 129 | service: "drive", | ||
| 130 | version: "v3", | ||
| 131 | discoveryUrl: defaultGoogleDiscoveryUrl("drive", "v3"), | ||
| 132 | }), | ||
| 133 | googleDiscoveryTemplate({ | ||
| 134 | id: "google-gmail", | ||
| 135 | name: "Gmail", | ||
| 136 | summary: "Messages, threads, labels, drafts, and mailbox automation.", | ||
| 137 | service: "gmail", | ||
| 138 | version: "v1", | ||
| 139 | discoveryUrl: "https://gmail.googleapis.com/$discovery/rest?version=v1", | ||
| 140 | }), | ||
| 141 | googleDiscoveryTemplate({ | ||
| 142 | id: "google-docs", | ||
| 143 | name: "Google Docs", | ||
| 144 | summary: "Documents, structural edits, text ranges, and formatting.", | ||
| 145 | service: "docs", | ||
| 146 | version: "v1", | ||
| 147 | discoveryUrl: "https://docs.googleapis.com/$discovery/rest?version=v1", | ||
| 148 | }), | ||
| 149 | googleDiscoveryTemplate({ | ||
| 150 | id: "google-sheets", | ||
| 151 | name: "Google Sheets", | ||
| 152 | summary: "Spreadsheets, values, ranges, formatting, and batch updates.", | ||
| 153 | service: "sheets", | ||
| 154 | version: "v4", | ||
| 155 | discoveryUrl: "https://sheets.googleapis.com/$discovery/rest?version=v4", | ||
| 156 | }), | ||
| 157 | googleDiscoveryTemplate({ | ||
| 158 | id: "google-slides", | ||
| 159 | name: "Google Slides", | ||
| 160 | summary: "Presentations, slides, page elements, and deck updates.", | ||
| 161 | service: "slides", | ||
| 162 | version: "v1", | ||
| 163 | discoveryUrl: "https://slides.googleapis.com/$discovery/rest?version=v1", | ||
| 164 | }), | ||
| 165 | googleDiscoveryTemplate({ | ||
| 166 | id: "google-forms", | ||
| 167 | name: "Google Forms", | ||
| 168 | summary: "Forms, questions, responses, quizzes, and form metadata.", | ||
| 169 | service: "forms", | ||
| 170 | version: "v1", | ||
| 171 | discoveryUrl: "https://forms.googleapis.com/$discovery/rest?version=v1", | ||
| 172 | }), | ||
| 173 | googleDiscoveryTemplate({ | ||
| 174 | id: "google-search-console", | ||
| 175 | name: "Google Search Console", | ||
| 176 | summary: "Sites, sitemaps, URL inspection, and search performance.", | ||
| 177 | service: "searchconsole", | ||
| 178 | version: "v1", | ||
| 179 | discoveryUrl: "https://searchconsole.googleapis.com/$discovery/rest?version=v1", | ||
| 180 | }), | ||
| 181 | googleDiscoveryTemplate({ | ||
| 182 | id: "google-people", | ||
| 183 | name: "Google People", | ||
| 184 | summary: "Contacts, profiles, directory people, and contact groups.", | ||
| 185 | service: "people", | ||
| 186 | version: "v1", | ||
| 187 | discoveryUrl: "https://people.googleapis.com/$discovery/rest?version=v1", | ||
| 188 | }), | ||
| 189 | googleDiscoveryTemplate({ | ||
| 190 | id: "google-tasks", | ||
| 191 | name: "Google Tasks", | ||
| 192 | summary: "Task lists, task items, notes, and due dates.", | ||
| 193 | service: "tasks", | ||
| 194 | version: "v1", | ||
| 195 | discoveryUrl: "https://tasks.googleapis.com/$discovery/rest?version=v1", | ||
| 196 | }), | ||
| 197 | googleDiscoveryTemplate({ | ||
| 198 | id: "google-chat", | ||
| 199 | name: "Google Chat", | ||
| 200 | summary: "Spaces, messages, members, reactions, and chat workflows.", | ||
| 201 | service: "chat", | ||
| 202 | version: "v1", | ||
| 203 | discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1", | ||
| 204 | }), | ||
| 205 | googleDiscoveryTemplate({ | ||
| 206 | id: "google-keep", | ||
| 207 | name: "Google Keep", | ||
| 208 | summary: "Notes, lists, attachments, and collaborative annotations.", | ||
| 209 | service: "keep", | ||
| 210 | version: "v1", | ||
| 211 | discoveryUrl: "https://keep.googleapis.com/$discovery/rest?version=v1", | ||
| 212 | }), | ||
| 213 | googleDiscoveryTemplate({ | ||
| 214 | id: "google-classroom", | ||
| 215 | name: "Google Classroom", | ||
| 216 | summary: "Courses, rosters, coursework, submissions, and grading data.", | ||
| 217 | service: "classroom", | ||
| 218 | version: "v1", | ||
| 219 | discoveryUrl: "https://classroom.googleapis.com/$discovery/rest?version=v1", | ||
| 220 | }), | ||
| 221 | googleDiscoveryTemplate({ | ||
| 222 | id: "google-admin-directory", | ||
| 223 | name: "Google Admin Directory", | ||
| 224 | summary: "Users, groups, org units, roles, and directory resources.", | ||
| 225 | service: "admin", | ||
| 226 | version: "directory_v1", | ||
| 227 | discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=directory_v1", | ||
| 228 | }), | ||
| 229 | googleDiscoveryTemplate({ | ||
| 230 | id: "google-admin-reports", | ||
| 231 | name: "Google Admin Reports", | ||
| 232 | summary: "Audit events, usage reports, and admin activity logs.", | ||
| 233 | service: "admin", | ||
| 234 | version: "reports_v1", | ||
| 235 | discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=reports_v1", | ||
| 236 | }), | ||
| 237 | googleDiscoveryTemplate({ | ||
| 238 | id: "google-apps-script", | ||
| 239 | name: "Google Apps Script", | ||
| 240 | summary: "Projects, deployments, execution, and script metadata.", | ||
| 241 | service: "script", | ||
| 242 | version: "v1", | ||
| 243 | discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1", | ||
| 244 | }), | ||
| 245 | googleDiscoveryTemplate({ | ||
| 246 | id: "google-bigquery", | ||
| 247 | name: "Google BigQuery", | ||
| 248 | summary: "Datasets, tables, jobs, routines, and analytics workflows.", | ||
| 249 | service: "bigquery", | ||
| 250 | version: "v2", | ||
| 251 | discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2", | ||
| 252 | }), | ||
| 253 | googleDiscoveryTemplate({ | ||
| 254 | id: "google-cloud-resource-manager", | ||
| 255 | name: "Google Cloud Resource Manager", | ||
| 256 | summary: "Projects, folders, organizations, and IAM hierarchy.", | ||
| 257 | service: "cloudresourcemanager", | ||
| 258 | version: "v3", | ||
| 259 | discoveryUrl: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3", | ||
| 260 | }), | ||
| 261 | googleDiscoveryTemplate({ | ||
| 262 | id: "google-youtube-data", | ||
| 263 | name: "YouTube Data", | ||
| 264 | summary: "Channels, playlists, videos, comments, captions, and uploads.", | ||
| 265 | service: "youtube", | ||
| 266 | version: "v3", | ||
| 267 | discoveryUrl: "https://youtube.googleapis.com/$discovery/rest?version=v3", | ||
| 268 | }), | ||
| 269 | ]; | ||
| 270 | |||
| 271 | const presetString = ( | ||
| 272 | search: Record<string, unknown>, | ||
| 273 | key: string, | ||
| 274 | ): string | null => { | ||
| 275 | const value = search[key]; | ||
| 276 | return typeof value === "string" && value.trim().length > 0 | ||
| 277 | ? value.trim() | ||
| 278 | : null; | ||
| 279 | }; | ||
| 280 | |||
| 281 | const parsedGoogleDiscoveryInputFromUrl = ( | ||
| 282 | value: string | null, | ||
| 283 | ): Pick<GoogleDiscoveryConnectInput, "name" | "service" | "version" | "discoveryUrl"> | null => { | ||
| 284 | if (!value) { | ||
| 285 | return null; | ||
| 286 | } | ||
| 287 | |||
| 288 | try { | ||
| 289 | const url = new URL(value); | ||
| 290 | const byDirectory = url.pathname.match(/^\/discovery\/v1\/apis\/([^/]+)\/([^/]+)\/rest$/); | ||
| 291 | if (byDirectory) { | ||
| 292 | const service = decodeURIComponent(byDirectory[1] ?? ""); | ||
| 293 | const version = decodeURIComponent(byDirectory[2] ?? ""); | ||
| 294 | const knownTemplate = GOOGLE_DISCOVERY_BATCH_TEMPLATES.find( | ||
| 295 | (entry) => entry.service === service && entry.version === version, | ||
| 296 | ); | ||
| 297 | return { | ||
| 298 | name: knownTemplate?.name ?? `Google ${service}`, | ||
| 299 | service, | ||
| 300 | version, | ||
| 301 | discoveryUrl: url.toString(), | ||
| 302 | }; | ||
| 303 | } | ||
| 304 | |||
| 305 | const version = url.searchParams.get("version")?.trim() ?? ""; | ||
| 306 | const isHostScopedDiscovery = | ||
| 307 | url.pathname === "/$discovery/rest" | ||
| 308 | && url.hostname.endsWith(".googleapis.com") | ||
| 309 | && url.hostname !== "www.googleapis.com"; | ||
| 310 | if (version && isHostScopedDiscovery) { | ||
| 311 | const service = url.hostname.split(".")[0] ?? ""; | ||
| 312 | const knownTemplate = GOOGLE_DISCOVERY_BATCH_TEMPLATES.find( | ||
| 313 | (entry) => entry.service === service && entry.version === version, | ||
| 314 | ); | ||
| 315 | return { | ||
| 316 | name: knownTemplate?.name ?? `Google ${service}`, | ||
| 317 | service, | ||
| 318 | version, | ||
| 319 | discoveryUrl: url.toString(), | ||
| 320 | }; | ||
| 321 | } | ||
| 322 | } catch {} | ||
| 323 | |||
| 324 | return null; | ||
| 325 | }; | ||
| 326 | |||
| 327 | const googleInputFromSearch = ( | ||
| 328 | search: Record<string, unknown>, | ||
| 329 | ): GoogleDiscoveryConnectInput => { | ||
| 330 | const defaults = defaultGoogleInput(); | ||
| 331 | const parsedFromInputUrl = parsedGoogleDiscoveryInputFromUrl( | ||
| 332 | presetString(search, "inputUrl") | ||
| 333 | ?? presetString(search, "input") | ||
| 334 | ?? presetString(search, "url"), | ||
| 335 | ); | ||
| 336 | const service = | ||
| 337 | presetString(search, "presetService") | ||
| 338 | ?? parsedFromInputUrl?.service | ||
| 339 | ?? defaults.service; | ||
| 340 | const version = | ||
| 341 | presetString(search, "presetVersion") | ||
| 342 | ?? parsedFromInputUrl?.version | ||
| 343 | ?? defaults.version; | ||
| 344 | |||
| 345 | return { | ||
| 346 | ...defaults, | ||
| 347 | name: | ||
| 348 | presetString(search, "presetName") | ||
| 349 | ?? parsedFromInputUrl?.name | ||
| 350 | ?? defaults.name, | ||
| 351 | service, | ||
| 352 | version, | ||
| 353 | discoveryUrl: | ||
| 354 | presetString(search, "presetDiscoveryUrl") | ||
| 355 | ?? parsedFromInputUrl?.discoveryUrl | ||
| 356 | ?? defaultGoogleDiscoveryUrl(service, version), | ||
| 357 | }; | ||
| 358 | }; | ||
| 359 | |||
| 97 | const stringifyStringMap = ( | 360 | const stringifyStringMap = ( |
| 98 | value: Record<string, string> | null | undefined, | 361 | value: Record<string, string> | null | undefined, |
| 99 | ): string => | 362 | ): string => |
| @@ -201,6 +464,167 @@ const waitForOauthPopupResult = async ( · 101 unmodified lines | |||
| 201 | }, 400); | 464 | }, 400); |
| 202 | }); | 465 | }); |
| 203 | 466 | ||
| 467 | const openGoogleOauthPopup = (name: string): Window | null => { | ||
| 468 | const popup = window.open("", name, "width=560,height=760"); | ||
| 469 | if (!popup) { | ||
| 470 | return null; | ||
| 471 | } | ||
| 472 | |||
| 473 | try { | ||
| 474 | popup.document.title = "Sign in with Google"; | ||
| 475 | popup.document.body.innerHTML = ` | ||
| 476 | <main style="font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; color: #333;"> | ||
| 477 | <div style="width: 20px; height: 20px; border: 2px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; margin-bottom: 16px;"></div> | ||
| 478 | <p style="margin: 0; font-size: 14px; color: #888;">Redirecting to Google…</p> | ||
| 479 | <style>@keyframes spin { to { transform: rotate(360deg); } }</style> | ||
| 480 | </main> | ||
| 481 | `; | ||
| 482 | } catch { | ||
| 483 | // Ignore browsers that don't allow writing to the popup document here. | ||
| 484 | } | ||
| 485 | |||
| 486 | return popup; | ||
| 487 | }; | ||
| 488 | |||
| 489 | const CREATE_SECRET_VALUE = "__create_google_secret__"; | ||
| 490 | |||
| 491 | function SecretSelectOrCreateField(props: { | ||
| 492 | label: string; | ||
| 493 | value: string; | ||
| 494 | emptyLabel: string; | ||
| 495 | onChange: (value: string) => void; | ||
| 496 | }) { | ||
| 497 | const secrets = useSecrets(); | ||
| 498 | const createSecret = useCreateSecret(); | ||
| 499 | const [draftName, setDraftName] = useState(""); | ||
| 500 | const [draftValue, setDraftValue] = useState(""); | ||
| 501 | const [createError, setCreateError] = useState<string | null>(null); | ||
| 502 | const [showCreate, setShowCreate] = useState(false); | ||
| 503 | |||
| 504 | const handleSelectChange = (nextValue: string) => { | ||
| 505 | if (nextValue === CREATE_SECRET_VALUE) { | ||
| 506 | setShowCreate(true); | ||
| 507 | props.onChange(""); | ||
| 508 | return; | ||
| 509 | } | ||
| 510 | |||
| 511 | setShowCreate(false); | ||
| 512 | setCreateError(null); | ||
| 513 | props.onChange(nextValue); | ||
| 514 | }; | ||
| 515 | |||
| 516 | const handleCreate = async () => { | ||
| 517 | const trimmedName = draftName.trim(); | ||
| 518 | if (!trimmedName) { | ||
| 519 | setCreateError("Secret name is required."); | ||
| 520 | return; | ||
| 521 | } | ||
| 522 | if (!draftValue.trim()) { | ||
| 523 | setCreateError("Secret value is required."); | ||
| 524 | return; | ||
| 525 | } | ||
| 526 | |||
| 527 | try { | ||
| 528 | setCreateError(null); | ||
| 529 | const created = await createSecret.mutateAsync({ | ||
| 530 | name: trimmedName, | ||
| 531 | value: draftValue, | ||
| 532 | }); | ||
| 533 | props.onChange(JSON.stringify({ | ||
| 534 | providerId: created.providerId, | ||
| 535 | handle: created.id, | ||
| 536 | })); | ||
| 537 | setDraftName(""); | ||
| 538 | setDraftValue(""); | ||
| 539 | setShowCreate(false); | ||
| 540 | } catch (cause) { | ||
| 541 | setCreateError(cause instanceof Error ? cause.message : "Failed creating secret."); | ||
| 542 | } | ||
| 543 | }; | ||
| 544 | |||
| 545 | return ( | ||
| 546 | <div className="grid gap-2"> | ||
| 547 | <span className="text-xs font-medium text-foreground">{props.label}</span> | ||
| 548 | <select | ||
| 549 | value={showCreate ? CREATE_SECRET_VALUE : props.value} | ||
| 550 | onChange={(event) => handleSelectChange(event.target.value)} | ||
| 551 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 552 | > | ||
| 553 | <option value="">{props.emptyLabel}</option> | ||
| 554 | {secrets.status === "ready" && | ||
| 555 | secrets.data.map((secret) => ( | ||
| 556 | <option | ||
| 557 | key={`${secret.providerId}:${secret.id}`} | ||
| 558 | value={JSON.stringify({ | ||
| 559 | providerId: secret.providerId, | ||
| 560 | handle: secret.id, | ||
| 561 | })} | ||
| 562 | > | ||
| 563 | {secret.name ?? secret.id} | ||
| 564 | </option> | ||
| 565 | ))} | ||
| 566 | <option value={CREATE_SECRET_VALUE}>Create new secret</option> | ||
| 567 | </select> | ||
| 568 | |||
| 569 | {showCreate && ( | ||
| 570 | <div className="space-y-3 border-l-2 border-border pl-4"> | ||
| 571 | <input | ||
| 572 | value={draftName} | ||
| 573 | onChange={(event) => setDraftName(event.target.value)} | ||
| 574 | placeholder="Google client secret" | ||
| 575 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 576 | /> | ||
| 577 | <textarea | ||
| 578 | value={draftValue} | ||
| 579 | onChange={(event) => setDraftValue(event.target.value)} | ||
| 580 | rows={3} | ||
| 581 | placeholder="Paste the secret value" | ||
| 582 | className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 583 | /> | ||
| 584 | {createError && ( | ||
| 585 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive"> | ||
| 586 | {createError} | ||
| 587 | </div> | ||
| 588 | )} | ||
| 589 | <div className="flex items-center gap-2"> | ||
| 590 | <button | ||
| 591 | type="button" | ||
| 592 | onClick={() => { | ||
| 593 | void handleCreate(); | ||
| 594 | }} | ||
| 595 | disabled={createSecret.status === "pending"} | ||
| 596 | className="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50" | ||
| 597 | > | ||
| 598 | {createSecret.status === "pending" ? "Creating..." : "Create Secret"} | ||
| 599 | </button> | ||
| 600 | <button | ||
| 601 | type="button" | ||
| 602 | onClick={() => { | ||
| 603 | setShowCreate(false); | ||
| 604 | setCreateError(null); | ||
| 605 | }} | ||
| 606 | className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50" | ||
| 607 | > | ||
| 608 | Cancel | ||
| 609 | </button> | ||
| 610 | </div> | ||
| 611 | </div> | ||
| 612 | )} | ||
| 613 | </div> | ||
| 614 | ); | ||
| 615 | } | ||
| 616 | |||
| 617 | const batchSourceInputFromTemplate = ( | ||
| 618 | template: GoogleDiscoveryBatchTemplate, | ||
| 619 | ): GoogleDiscoveryBatchSourceInput => ({ | ||
| 620 | name: template.name, | ||
| 621 | service: template.service, | ||
| 622 | version: template.version, | ||
| 623 | discoveryUrl: template.discoveryUrl, | ||
| 624 | defaultHeaders: null, | ||
| 625 | scopes: [], | ||
| 626 | }); | ||
| 627 | |||
| 204 | function GoogleDiscoverySourceForm(props: { | 628 | function GoogleDiscoverySourceForm(props: { |
| 205 | initialValue: GoogleDiscoveryConnectInput; | 629 | initialValue: GoogleDiscoveryConnectInput; |
| 206 | mode: "create" | "edit"; | 630 | mode: "create" | "edit"; |
| @@ -208,7 +632,6 @@ function GoogleDiscoverySourceForm(props: { · 1 unmodified line | |||
| 208 | }) { | 632 | }) { |
| 209 | const installation = useLocalInstallation(); | 633 | const installation = useLocalInstallation(); |
| 210 | const client = getGoogleDiscoveryHttpClient(); | 634 | const client = getGoogleDiscoveryHttpClient(); |
| 211 | const secrets = useSecrets(); | ||
| 212 | const startOAuth = useAtomSet( | 635 | const startOAuth = useAtomSet( |
| 213 | client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "startOAuth"), | 636 | client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "startOAuth"), |
| 214 | { mode: "promise" }, | 637 | { mode: "promise" }, |
| @@ -249,6 +672,11 @@ function GoogleDiscoverySourceForm(props: { · 34 unmodified lines | |||
| 249 | throw new Error("Workspace is still loading."); | 672 | throw new Error("Workspace is still loading."); |
| 250 | } | 673 | } |
| 251 | 674 | ||
| 675 | const popup = openGoogleOauthPopup("executor-google-discovery-oauth"); | ||
| 676 | if (!popup) { | ||
| 677 | throw new Error("Failed opening Google OAuth popup. Allow popups for this site and try again."); | ||
| 678 | } | ||
| 679 | |||
| 252 | const payload: GoogleDiscoveryStartOAuthInput = { | 680 | const payload: GoogleDiscoveryStartOAuthInput = { |
| 253 | service: service.trim(), | 681 | service: service.trim(), |
| 254 | version: version.trim(), | 682 | version: version.trim(), |
| @@ -266,26 +694,29 @@ function GoogleDiscoverySourceForm(props: { · 11 unmodified lines | |||
| 266 | ).toString(), | 694 | ).toString(), |
| 267 | }; | 695 | }; |
| 268 | 696 | ||
| 269 | const started = await startOAuth({ | 697 | let started; |
| 270 | path: { | 698 | try { |
| 271 | workspaceId: installation.data.scopeId, | 699 | started = await startOAuth({ |
| 272 | }, | 700 | path: { |
| 273 | payload, | 701 | workspaceId: installation.data.scopeId, |
| 274 | }); | 702 | }, |
| 275 | 703 | payload, | |
| 276 | const popup = window.open( | 704 | }); |
| 277 | started.authorizationUrl, | 705 | } catch (cause) { |
| 278 | "executor-google-discovery-oauth", | 706 | popup.close(); |
| 279 | "width=560,height=760,noopener,noreferrer", | 707 | throw cause; |
| 280 | ); | ||
| 281 | if (!popup) { | ||
| 282 | throw new Error("Failed opening Google OAuth popup."); | ||
| 283 | } | 708 | } |
| 284 | 709 | ||
| 710 | popup.location.replace(started.authorizationUrl); | ||
| 711 | popup.focus(); | ||
| 712 | |||
| 285 | const result = await waitForOauthPopupResult(started.sessionId); | 713 | const result = await waitForOauthPopupResult(started.sessionId); |
| 286 | if (!result.ok) { | 714 | if (!result.ok) { |
| 287 | throw new Error(result.error); | 715 | throw new Error(result.error); |
| 288 | } | 716 | } |
| 717 | if (result.mode !== "single") { | ||
| 718 | throw new Error("Unexpected batch OAuth result for single-source Google setup."); | ||
| 719 | } | ||
| 289 | 720 | ||
| 290 | setScopesText(stringifyScopes(result.auth.scopes)); | 721 | setScopesText(stringifyScopes(result.auth.scopes)); |
| 291 | setOauthAuth(result.auth); | 722 | setOauthAuth(result.auth); |
| @@ -293,82 +724,71 @@ function GoogleDiscoverySourceForm(props: { · 1 unmodified line | |||
| 293 | }; | 724 | }; |
| 294 | 725 | ||
| 295 | return ( | 726 | return ( |
| 296 | <div className="space-y-4 rounded-2xl border border-border bg-card p-5"> | 727 | <div className="space-y-6 rounded-xl border border-border p-6"> |
| 297 | <div> | 728 | <div className="grid gap-4 sm:grid-cols-2"> |
| 298 | <h2 className="text-lg font-semibold text-foreground"> | 729 | <label className="grid gap-2 sm:col-span-2"> |
| 299 | {props.mode === "create" | ||
| 300 | ? "Connect Google Discovery Source" | ||
| 301 | : "Edit Google Discovery Source"} | ||
| 302 | </h2> | ||
| 303 | <p className="mt-1 text-sm text-muted-foreground"> | ||
| 304 | Google Discovery now owns its API metadata and OAuth flow inside the plugin. | ||
| 305 | </p> | ||
| 306 | </div> | ||
| 307 | |||
| 308 | <div className="grid gap-4 md:grid-cols-2"> | ||
| 309 | <label className="grid gap-1.5"> | ||
| 310 | <span className="text-xs font-medium text-foreground">Name</span> | 730 | <span className="text-xs font-medium text-foreground">Name</span> |
| 311 | <input | 731 | <input |
| 312 | value={name} | 732 | value={name} |
| 313 | onChange={(event) => setName(event.target.value)} | 733 | onChange={(event) => setName(event.target.value)} |
| 314 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 734 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 315 | /> | 735 | /> |
| 316 | </label> | 736 | </label> |
| 317 | <label className="grid gap-1.5"> | 737 | <label className="grid gap-2"> |
| 318 | <span className="text-xs font-medium text-foreground">Service</span> | 738 | <span className="text-xs font-medium text-foreground">Service</span> |
| 319 | <input | 739 | <input |
| 320 | value={service} | 740 | value={service} |
| 321 | onChange={(event) => setService(event.target.value)} | 741 | onChange={(event) => setService(event.target.value)} |
| 322 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 742 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 323 | /> | 743 | /> |
| 324 | </label> | 744 | </label> |
| 325 | <label className="grid gap-1.5"> | 745 | <label className="grid gap-2"> |
| 326 | <span className="text-xs font-medium text-foreground">Version</span> | 746 | <span className="text-xs font-medium text-foreground">Version</span> |
| 327 | <input | 747 | <input |
| 328 | value={version} | 748 | value={version} |
| 329 | onChange={(event) => setVersion(event.target.value)} | 749 | onChange={(event) => setVersion(event.target.value)} |
| 330 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 750 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 331 | /> | 751 | /> |
| 332 | </label> | 752 | </label> |
| 333 | <label className="grid gap-1.5"> | 753 | <label className="grid gap-2 sm:col-span-2"> |
| 334 | <span className="text-xs font-medium text-foreground">Discovery URL</span> | 754 | <span className="text-xs font-medium text-foreground">Discovery URL</span> |
| 335 | <input | 755 | <input |
| 336 | value={discoveryUrl} | 756 | value={discoveryUrl} |
| 337 | onChange={(event) => setDiscoveryUrl(event.target.value)} | 757 | onChange={(event) => setDiscoveryUrl(event.target.value)} |
| 338 | placeholder={defaultGoogleDiscoveryUrl(service || "sheets", version || "v4")} | 758 | placeholder={defaultGoogleDiscoveryUrl(service || "sheets", version || "v4")} |
| 339 | className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 759 | className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 340 | /> | 760 | /> |
| 341 | </label> | 761 | </label> |
| 342 | </div> | 762 | </div> |
| 343 | 763 | ||
| 344 | <label className="grid gap-1.5"> | 764 | <label className="grid gap-2"> |
| 345 | <span className="text-xs font-medium text-foreground">Default Headers</span> | 765 | <span className="text-xs font-medium text-foreground">Default Headers</span> |
| 346 | <textarea | 766 | <textarea |
| 347 | value={headersText} | 767 | value={headersText} |
| 348 | onChange={(event) => setHeadersText(event.target.value)} | 768 | onChange={(event) => setHeadersText(event.target.value)} |
| 349 | rows={4} | 769 | rows={3} |
| 350 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 770 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 351 | /> | 771 | /> |
| 352 | </label> | 772 | </label> |
| 353 | 773 | ||
| 354 | <label className="grid gap-1.5"> | 774 | <label className="grid gap-2"> |
| 355 | <span className="text-xs font-medium text-foreground">Scopes</span> | 775 | <span className="text-xs font-medium text-foreground">Scopes</span> |
| 356 | <textarea | 776 | <textarea |
| 357 | value={scopesText} | 777 | value={scopesText} |
| 358 | onChange={(event) => setScopesText(event.target.value)} | 778 | onChange={(event) => setScopesText(event.target.value)} |
| 359 | rows={4} | 779 | rows={3} |
| 360 | placeholder="Leave blank to infer from the discovery document." | 780 | placeholder="Leave blank to infer from the discovery document." |
| 361 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 781 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 362 | /> | 782 | /> |
| 363 | </label> | 783 | </label> |
| 364 | 784 | ||
| 365 | <label className="grid gap-1.5"> | 785 | <label className="grid gap-2"> |
| 366 | <span className="text-xs font-medium text-foreground">Auth</span> | 786 | <span className="text-xs font-medium text-foreground">Auth</span> |
| 367 | <select | 787 | <select |
| 368 | value={authKind} | 788 | value={authKind} |
| 369 | onChange={(event) => | 789 | onChange={(event) => |
| 370 | setAuthKind(event.target.value as GoogleDiscoveryConnectionAuth["kind"])} | 790 | setAuthKind(event.target.value as GoogleDiscoveryConnectionAuth["kind"])} |
| 371 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 791 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 372 | > | 792 | > |
| 373 | <option value="none">None</option> | 793 | <option value="none">None</option> |
| 374 | <option value="bearer">Bearer Secret</option> | 794 | <option value="bearer">Bearer Secret</option> |
| @@ -377,62 +797,32 @@ function GoogleDiscoverySourceForm(props: { · 2 unmodified lines | |||
| 377 | </label> | 797 | </label> |
| 378 | 798 | ||
| 379 | {authKind === "bearer" && ( | 799 | {authKind === "bearer" && ( |
| 380 | <label className="grid gap-1.5"> | 800 | <div className="border-l-2 border-border pl-4"> |
| 381 | <span className="text-xs font-medium text-foreground">Secret</span> | 801 | <SecretSelectOrCreateField |
| 382 | <select | 802 | label="Secret" |
| 383 | value={bearerSecretRef} | 803 | value={bearerSecretRef} |
| 384 | onChange={(event) => setBearerSecretRef(event.target.value)} | 804 | emptyLabel="Select a secret" |
| 385 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 805 | onChange={setBearerSecretRef} |
| 386 | > | 806 | /> |
| 387 | <option value="">Select a secret</option> | 807 | </div> |
| 388 | {secrets.status === "ready" && | ||
| 389 | secrets.data.map((secret) => ( | ||
| 390 | <option | ||
| 391 | key={`${secret.providerId}:${secret.id}`} | ||
| 392 | value={JSON.stringify({ | ||
| 393 | providerId: secret.providerId, | ||
| 394 | handle: secret.id, | ||
| 395 | })} | ||
| 396 | > | ||
| 397 | {secret.name ?? secret.id} | ||
| 398 | </option> | ||
| 399 | ))} | ||
| 400 | </select> | ||
| 401 | </label> | ||
| 402 | )} | 808 | )} |
| 403 | 809 | ||
| 404 | {authKind === "oauth2" && ( | 810 | {authKind === "oauth2" && ( |
| 405 | <div className="space-y-4 rounded-xl border border-border/70 bg-background/50 p-4"> | 811 | <div className="space-y-4 border-l-2 border-border pl-4"> |
| 406 | <label className="grid gap-1.5"> | 812 | <label className="grid gap-2"> |
| 407 | <span className="text-xs font-medium text-foreground">Client ID</span> | 813 | <span className="text-xs font-medium text-foreground">Client ID</span> |
| 408 | <input | 814 | <input |
| 409 | value={clientId} | 815 | value={clientId} |
| 410 | onChange={(event) => setClientId(event.target.value)} | 816 | onChange={(event) => setClientId(event.target.value)} |
| 411 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 817 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 412 | /> | 818 | /> |
| 413 | </label> | 819 | </label> |
| 414 | <label className="grid gap-1.5"> | 820 | <SecretSelectOrCreateField |
| 415 | <span className="text-xs font-medium text-foreground">Client Secret</span> | 821 | label="Client Secret" |
| 416 | <select | 822 | value={clientSecretRef} |
| 417 | value={clientSecretRef} | 823 | emptyLabel="Public client / no secret" |
| 418 | onChange={(event) => setClientSecretRef(event.target.value)} | 824 | onChange={setClientSecretRef} |
| 419 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 825 | /> |
| 420 | > | ||
| 421 | <option value="">Public client / no secret</option> | ||
| 422 | {secrets.status === "ready" && | ||
| 423 | secrets.data.map((secret) => ( | ||
| 424 | <option | ||
| 425 | key={`${secret.providerId}:${secret.id}`} | ||
| 426 | value={JSON.stringify({ | ||
| 427 | providerId: secret.providerId, | ||
| 428 | handle: secret.id, | ||
| 429 | })} | ||
| 430 | > | ||
| 431 | {secret.name ?? secret.id} | ||
| 432 | </option> | ||
| 433 | ))} | ||
| 434 | </select> | ||
| 435 | </label> | ||
| 436 | <div className="flex items-center gap-3"> | 826 | <div className="flex items-center gap-3"> |
| 437 | <button | 827 | <button |
| 438 | type="button" | 828 | type="button" |
| @@ -455,25 +845,23 @@ function GoogleDiscoverySourceForm(props: { · 16 unmodified lines | |||
| 455 | {oauthStatus === "pending" | 845 | {oauthStatus === "pending" |
| 456 | ? "Connecting..." | 846 | ? "Connecting..." |
| 457 | : oauthStatus === "connected" | 847 | : oauthStatus === "connected" |
| 458 | ? "Reconnect OAuth" | 848 | ? "Reconnect" |
| 459 | : "Connect OAuth"} | 849 | : "Connect with Google"} |
| 460 | </button> | 850 | </button> |
| 461 | <div className="text-xs text-muted-foreground"> | 851 | {oauthStatus === "connected" && ( |
| 462 | {oauthStatus === "connected" | 852 | <span className="text-xs text-primary">Connected</span> |
| 463 | ? "OAuth tokens are ready and will be saved with this source." | 853 | )} |
| 464 | : "The plugin opens Google OAuth in a popup and stores token refs locally."} | ||
| 465 | </div> | ||
| 466 | </div> | 854 | </div> |
| 467 | </div> | 855 | </div> |
| 468 | )} | 856 | )} |
| 469 | 857 | ||
| 470 | {error && ( | 858 | {error && ( |
| 471 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> | 859 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive"> |
| 472 | {error} | 860 | {error} |
| 473 | </div> | 861 | </div> |
| 474 | )} | 862 | )} |
| 475 | 863 | ||
| 476 | <div className="flex items-center gap-3"> | 864 | <div className="flex items-center justify-end"> |
| 477 | <button | 865 | <button |
| 478 | type="button" | 866 | type="button" |
| 479 | onClick={() => { | 867 | onClick={() => { |
| @@ -491,7 +879,7 @@ function GoogleDiscoverySourceForm(props: { · 11 unmodified lines | |||
| 491 | : oauthAuth | 879 | : oauthAuth |
| 492 | ? oauthAuth | 880 | ? oauthAuth |
| 493 | : (() => { | 881 | : (() => { |
| 494 | throw new Error("Finish Google OAuth before saving."); | 882 | throw new Error("Complete Google sign-in before saving."); |
| 495 | })(); | 883 | })(); |
| 496 | 884 | ||
| 497 | await submitMutation.mutateAsync({ | 885 | await submitMutation.mutateAsync({ |
| @@ -507,23 +895,271 @@ function GoogleDiscoverySourceForm(props: { · 9 unmodified lines | |||
| 507 | setError( | 895 | setError( |
| 508 | cause instanceof Error | 896 | cause instanceof Error |
| 509 | ? cause.message | 897 | ? cause.message |
| 510 | : "Failed saving Google Discovery source.", | 898 | : "Failed saving source.", |
| 511 | ); | 899 | ); |
| 512 | } | 900 | } |
| 513 | })(); | 901 | })(); |
| 514 | }} | 902 | }} |
| 515 | disabled={submitMutation.status === "pending"} | 903 | disabled={submitMutation.status === "pending"} |
| 516 | className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50" | 904 | className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50" |
| 517 | > | 905 | > |
| 518 | {submitMutation.status === "pending" | 906 | {submitMutation.status === "pending" |
| 519 | ? props.mode === "create" ? "Creating..." : "Saving..." | 907 | ? props.mode === "create" ? "Creating..." : "Saving..." |
| 520 | : props.mode === "create" ? "Create Source" : "Save Changes"} | 908 | : props.mode === "create" ? "Create Source" : "Save Changes"} |
| 521 | </button> | 909 | </button> |
| 910 | </div> | ||
| 911 | </div> | ||
| 912 | ); | ||
| 913 | } | ||
| 914 | |||
| 915 | function GoogleDiscoveryBatchConnectPanel(props: { | ||
| 916 | onConnected: (sources: ReadonlyArray<{ id: string; name: string }>) => void; | ||
| 917 | }) { | ||
| 918 | const installation = useLocalInstallation(); | ||
| 919 | const client = getGoogleDiscoveryHttpClient(); | ||
| 920 | const startBatchOAuth = useAtomSet( | ||
| 921 | client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "startBatchOAuth"), | ||
| 922 | { mode: "promise" }, | ||
| 923 | ); | ||
| 924 | const [selectedTemplateIds, setSelectedTemplateIds] = useState< | ||
| 925 | ReadonlyArray<string> | ||
| 926 | >([]); | ||
| 927 | const [clientId, setClientId] = useState(""); | ||
| 928 | const [clientSecretRef, setClientSecretRef] = useState(""); | ||
| 929 | const [error, setError] = useState<string | null>(null); | ||
| 930 | const [status, setStatus] = useState<"idle" | "connecting">("idle"); | ||
| 931 | |||
| 932 | const selectedTemplates = useMemo( | ||
| 933 | () => | ||
| 934 | GOOGLE_DISCOVERY_BATCH_TEMPLATES.filter((template) => | ||
| 935 | selectedTemplateIds.includes(template.id) | ||
| 936 | ), | ||
| 937 | [selectedTemplateIds], | ||
| 938 | ); | ||
| 939 | |||
| 940 | const toggleTemplate = (templateId: string) => { | ||
| 941 | setSelectedTemplateIds((current) => | ||
| 942 | current.includes(templateId) | ||
| 943 | ? current.filter((id) => id !== templateId) | ||
| 944 | : [...current, templateId] | ||
| 945 | ); | ||
| 946 | }; | ||
| 947 | |||
| 948 | const handleConnect = async () => { | ||
| 949 | if (installation.status !== "ready") { | ||
| 950 | setError("Workspace is still loading."); | ||
| 951 | return; | ||
| 952 | } | ||
| 953 | if (selectedTemplates.length === 0) { | ||
| 954 | setError("Select at least one Google API."); | ||
| 955 | return; | ||
| 956 | } | ||
| 957 | if (!clientId.trim()) { | ||
| 958 | setError("Client ID is required for batch Google OAuth."); | ||
| 959 | return; | ||
| 960 | } | ||
| 961 | |||
| 962 | try { | ||
| 963 | setError(null); | ||
| 964 | setStatus("connecting"); | ||
| 965 | const popup = openGoogleOauthPopup("executor-google-discovery-batch-oauth"); | ||
| 966 | if (!popup) { | ||
| 967 | throw new Error("Failed opening Google OAuth popup. Allow popups for this site and try again."); | ||
| 968 | } | ||
| 969 | |||
| 970 | let started; | ||
| 971 | try { | ||
| 972 | started = await startBatchOAuth({ | ||
| 973 | path: { | ||
| 974 | workspaceId: installation.data.scopeId, | ||
| 975 | }, | ||
| 976 | payload: { | ||
| 977 | sources: selectedTemplates.map(batchSourceInputFromTemplate), | ||
| 978 | clientId: clientId.trim(), | ||
| 979 | clientSecretRef: clientSecretRef | ||
| 980 | ? JSON.parse(clientSecretRef) | ||
| 981 | : null, | ||
| 982 | clientAuthentication: clientSecretRef ? "client_secret_post" : "none", | ||
| 983 | redirectUrl: new URL( | ||
| 984 | GOOGLE_DISCOVERY_OAUTH_CALLBACK_PATH, | ||
| 985 | window.location.origin, | ||
| 986 | ).toString(), | ||
| 987 | } satisfies GoogleDiscoveryStartBatchOAuthInput, | ||
| 988 | }); | ||
| 989 | } catch (cause) { | ||
| 990 | popup.close(); | ||
| 991 | throw cause; | ||
| 992 | } | ||
| 993 | |||
| 994 | popup.location.replace(started.authorizationUrl); | ||
| 995 | popup.focus(); | ||
| 996 | |||
| 997 | const result = await waitForOauthPopupResult(started.sessionId); | ||
| 998 | if (!result.ok) { | ||
| 999 | throw new Error(result.error); | ||
| 1000 | } | ||
| 1001 | if (result.mode !== "batch") { | ||
| 1002 | throw new Error("Unexpected single-source OAuth result for Google batch setup."); | ||
| 1003 | } | ||
| 1004 | |||
| 1005 | setSelectedTemplateIds([]); | ||
| 1006 | props.onConnected(result.sources); | ||
| 1007 | } catch (cause) { | ||
| 1008 | setError(cause instanceof Error ? cause.message : "Failed connecting Google APIs."); | ||
| 1009 | } finally { | ||
| 1010 | setStatus("idle"); | ||
| 1011 | } | ||
| 1012 | }; | ||
| 1013 | |||
| 1014 | return ( | ||
| 1015 | <div className="space-y-6 rounded-xl border border-border p-6"> | ||
| 1016 | <div> | ||
| 1017 | <h2 className="text-base font-semibold text-foreground"> | ||
| 1018 | Connect multiple Google APIs | ||
| 1019 | </h2> | ||
| 1020 | <p className="mt-1 text-sm text-muted-foreground"> | ||
| 1021 | Select APIs below and connect them all with a single sign-in. | ||
| 1022 | </p> | ||
| 1023 | </div> | ||
| 1024 | |||
| 1025 | <div className="flex items-center justify-between gap-3"> | ||
| 522 | <div className="text-xs text-muted-foreground"> | 1026 | <div className="text-xs text-muted-foreground"> |
| 523 | {props.mode === "create" | 1027 | {selectedTemplates.length === 0 |
| 524 | ? "The discovery document is imported as soon as the source is created." | 1028 | ? "No Google APIs selected." |
| 525 | : "Saving refreshes the imported Google API model."} | 1029 | : `${selectedTemplates.length} Google API${selectedTemplates.length === 1 ? "" : "s"} selected.`} |
| 1030 | </div> | ||
| 1031 | <div className="flex items-center gap-2"> | ||
| 1032 | <button | ||
| 1033 | type="button" | ||
| 1034 | onClick={() => setSelectedTemplateIds([])} | ||
| 1035 | className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50" | ||
| 1036 | > | ||
| 1037 | Clear | ||
| 1038 | </button> | ||
| 1039 | <button | ||
| 1040 | type="button" | ||
| 1041 | onClick={() => | ||
| 1042 | setSelectedTemplateIds( | ||
| 1043 | selectedTemplateIds.length === GOOGLE_DISCOVERY_BATCH_TEMPLATES.length | ||
| 1044 | ? [] | ||
| 1045 | : GOOGLE_DISCOVERY_BATCH_TEMPLATES.map((template) => template.id), | ||
| 1046 | ) | ||
| 1047 | } | ||
| 1048 | className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50" | ||
| 1049 | > | ||
| 1050 | {selectedTemplateIds.length === GOOGLE_DISCOVERY_BATCH_TEMPLATES.length | ||
| 1051 | ? "Deselect all" | ||
| 1052 | : "Select all"} | ||
| 1053 | </button> | ||
| 1054 | </div> | ||
| 1055 | </div> | ||
| 1056 | |||
| 1057 | <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> | ||
| 1058 | {GOOGLE_DISCOVERY_BATCH_TEMPLATES.map((template) => { | ||
| 1059 | const selected = selectedTemplateIds.includes(template.id); | ||
| 1060 | const serviceKey = template.service.replaceAll(".", ""); | ||
| 1061 | const iconUrl = GOOGLE_DISCOVERY_SERVICE_ICONS[serviceKey] ?? null; | ||
| 1062 | |||
| 1063 | return ( | ||
| 1064 | <button | ||
| 1065 | key={template.id} | ||
| 1066 | type="button" | ||
| 1067 | onClick={() => toggleTemplate(template.id)} | ||
| 1068 | className={ | ||
| 1069 | selected | ||
| 1070 | ? "rounded-lg border border-primary/30 bg-primary/5 px-4 py-3 text-left transition-colors" | ||
| 1071 | : "rounded-lg border border-border px-4 py-3 text-left transition-colors hover:bg-accent/40" | ||
| 1072 | } | ||
| 1073 | > | ||
| 1074 | <div className="flex items-start gap-3"> | ||
| 1075 | <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted"> | ||
| 1076 | {iconUrl | ||
| 1077 | ? ( | ||
| 1078 | <img | ||
| 1079 | src={iconUrl} | ||
| 1080 | alt="" | ||
| 1081 | className="size-5 object-contain" | ||
| 1082 | /> | ||
| 1083 | ) | ||
| 1084 | : ( | ||
| 1085 | <span className="text-sm font-semibold text-muted-foreground"> | ||
| 1086 | {template.name.slice(0, 1)} | ||
| 1087 | </span> | ||
| 1088 | )} | ||
| 1089 | </div> | ||
| 1090 | <div className="min-w-0 flex-1"> | ||
| 1091 | <div className="flex items-start justify-between gap-3"> | ||
| 1092 | <div className="min-w-0"> | ||
| 1093 | <div className="truncate text-sm font-semibold text-foreground"> | ||
| 1094 | {template.name} | ||
| 1095 | </div> | ||
| 1096 | <div className="mt-0.5 text-[11px] text-muted-foreground"> | ||
| 1097 | {template.service} {template.version} | ||
| 1098 | </div> | ||
| 1099 | </div> | ||
| 1100 | <div | ||
| 1101 | className={ | ||
| 1102 | selected | ||
| 1103 | ? "mt-0.5 size-3 rounded-full bg-primary" | ||
| 1104 | : "mt-0.5 size-3 rounded-full border border-border bg-background" | ||
| 1105 | } | ||
| 1106 | /> | ||
| 1107 | </div> | ||
| 1108 | <div className="mt-2 text-xs leading-5 text-muted-foreground"> | ||
| 1109 | {template.summary} | ||
| 1110 | </div> | ||
| 1111 | </div> | ||
| 1112 | </div> | ||
| 1113 | </button> | ||
| 1114 | ); | ||
| 1115 | })} | ||
| 1116 | </div> | ||
| 1117 | |||
| 1118 | <div className="grid gap-4 md:grid-cols-2"> | ||
| 1119 | <label className="grid gap-2 md:col-span-2"> | ||
| 1120 | <span className="text-xs font-medium text-foreground">Client ID</span> | ||
| 1121 | <input | ||
| 1122 | value={clientId} | ||
| 1123 | onChange={(event) => setClientId(event.target.value)} | ||
| 1124 | placeholder="1234567890-abcdef.apps.googleusercontent.com" | ||
| 1125 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 1126 | /> | ||
| 1127 | </label> | ||
| 1128 | <div className="md:col-span-2"> | ||
| 1129 | <SecretSelectOrCreateField | ||
| 1130 | label="Client Secret" | ||
| 1131 | value={clientSecretRef} | ||
| 1132 | emptyLabel="Public client / no secret" | ||
| 1133 | onChange={setClientSecretRef} | ||
| 1134 | /> | ||
| 1135 | </div> | ||
| 1136 | </div> | ||
| 1137 | |||
| 1138 | {error && ( | ||
| 1139 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> | ||
| 1140 | {error} | ||
| 526 | </div> | 1141 | </div> |
| 1142 | )} | ||
| 1143 | |||
| 1144 | <div className="flex items-center justify-end gap-3"> | ||
| 1145 | <button | ||
| 1146 | type="button" | ||
| 1147 | onClick={() => { | ||
| 1148 | void handleConnect(); | ||
| 1149 | }} | ||
| 1150 | disabled={ | ||
| 1151 | status === "connecting" | ||
| 1152 | || selectedTemplates.length === 0 | ||
| 1153 | || clientId.trim().length === 0 | ||
| 1154 | } | ||
| 1155 | className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50" | ||
| 1156 | > | ||
| 1157 | {status === "connecting" | ||
| 1158 | ? "Connecting..." | ||
| 1159 | : selectedTemplates.length > 0 | ||
| 1160 | ? `Connect ${selectedTemplates.length} Google API${selectedTemplates.length === 1 ? "" : "s"}` | ||
| 1161 | : "Connect Google APIs"} | ||
| 1162 | </button> | ||
| 527 | </div> | 1163 | </div> |
| 528 | </div> | 1164 | </div> |
| 529 | ); | 1165 | ); |
| @@ -531,6 +1167,7 @@ function GoogleDiscoverySourceForm(props: { · 1 unmodified line | |||
| 531 | 1167 | ||
| 532 | function GoogleDiscoveryAddPage() { | 1168 | function GoogleDiscoveryAddPage() { |
| 533 | const navigation = useSourcePluginNavigation(); | 1169 | const navigation = useSourcePluginNavigation(); |
| 1170 | const initialValue = googleInputFromSearch(useSourcePluginSearch()); | ||
| 534 | const installation = useLocalInstallation(); | 1171 | const installation = useLocalInstallation(); |
| 535 | const client = getGoogleDiscoveryHttpClient(); | 1172 | const client = getGoogleDiscoveryHttpClient(); |
| 536 | const createSource = useAtomSet( | 1173 | const createSource = useAtomSet( |
| @@ -543,27 +1180,52 @@ function GoogleDiscoveryAddPage() { · 6 unmodified lines | |||
| 543 | } | 1180 | } |
| 544 | 1181 | ||
| 545 | return ( | 1182 | return ( |
| 546 | <GoogleDiscoverySourceForm | 1183 | <div className="space-y-6"> |
| 547 | initialValue={defaultGoogleInput()} | 1184 | <GoogleDiscoveryBatchConnectPanel |
| 548 | mode="create" | 1185 | onConnected={(sources) => { |
| 549 | onSubmit={async (input) => { | 1186 | startTransition(() => { |
| 550 | const source = await createSource({ | 1187 | if (sources.length === 1) { |
| 551 | path: { | 1188 | void navigation.detail(sources[0]!.id, { |
| 552 | workspaceId: installation.data.scopeId, | 1189 | tab: "model", |
| 553 | }, | 1190 | }); |
| 554 | payload: input, | 1191 | return; |
| 555 | reactivityKeys: { | 1192 | } |
| 556 | sources: [installation.data.scopeId], | ||
| 557 | }, | ||
| 558 | }); | ||
| 559 | 1193 | ||
| 560 | startTransition(() => { | 1194 | void navigation.home(); |
| 561 | void navigation.detail(source.id, { | ||
| 562 | tab: "model", | ||
| 563 | }); | 1195 | }); |
| 564 | }); | 1196 | }} |
| 565 | }} | 1197 | /> |
| 566 | /> | 1198 | |
| 1199 | <div className="flex items-center gap-3"> | ||
| 1200 | <div className="h-px flex-1 bg-border" /> | ||
| 1201 | <div className="text-xs font-medium text-muted-foreground"> | ||
| 1202 | or add a single API | ||
| 1203 | </div> | ||
| 1204 | <div className="h-px flex-1 bg-border" /> | ||
| 1205 | </div> | ||
| 1206 | |||
| 1207 | <GoogleDiscoverySourceForm | ||
| 1208 | initialValue={initialValue} | ||
| 1209 | mode="create" | ||
| 1210 | onSubmit={async (input) => { | ||
| 1211 | const source = await createSource({ | ||
| 1212 | path: { | ||
| 1213 | workspaceId: installation.data.scopeId, | ||
| 1214 | }, | ||
| 1215 | payload: input, | ||
| 1216 | reactivityKeys: { | ||
| 1217 | sources: [installation.data.scopeId], | ||
| 1218 | }, | ||
| 1219 | }); | ||
| 1220 | |||
| 1221 | startTransition(() => { | ||
| 1222 | void navigation.detail(source.id, { | ||
| 1223 | tab: "model", | ||
| 1224 | }); | ||
| 1225 | }); | ||
| 1226 | }} | ||
| 1227 | /> | ||
| 1228 | </div> | ||
| 567 | ); | 1229 | ); |
| 568 | } | 1230 | } |
| 569 | 1231 | ||
| @@ -601,13 +1263,13 @@ function GoogleDiscoveryEditPage(props: { · 31 unmodified lines | |||
| 601 | if (Result.isFailure(configResult)) { | 1263 | if (Result.isFailure(configResult)) { |
| 602 | return ( | 1264 | return ( |
| 603 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> | 1265 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> |
| 604 | Failed loading Google Discovery config. | 1266 | Failed loading source configuration. |
| 605 | </div> | 1267 | </div> |
| 606 | ); | 1268 | ); |
| 607 | } | 1269 | } |
| 608 | 1270 | ||
| 609 | if (!Result.isSuccess(configResult)) { | 1271 | if (!Result.isSuccess(configResult)) { |
| 610 | return <div className="text-sm text-muted-foreground">Loading Google Discovery config...</div>; | 1272 | return <div className="text-sm text-muted-foreground">Loading configuration...</div>; |
| 611 | } | 1273 | } |
| 612 | 1274 | ||
| 613 | return ( | 1275 | return ( |
| @@ -653,6 +1315,8 @@ function GoogleDiscoveryDetailPage(props: { · 39 unmodified lines | |||
| 653 | client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "removeSource"), | 1315 | client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "removeSource"), |
| 654 | { mode: "promise" }, | 1316 | { mode: "promise" }, |
| 655 | ); | 1317 | ); |
| 1318 | const [confirmDelete, setConfirmDelete] = useState(false); | ||
| 1319 | const [isDeleting, setIsDeleting] = useState(false); | ||
| 656 | const configResult = useAtomValue( | 1320 | const configResult = useAtomValue( |
| 657 | installation.status === "ready" | 1321 | installation.status === "ready" |
| 658 | ? client.query(GOOGLE_DISCOVERY_PLUGIN_KEY, "getSourceConfig", { | 1322 | ? client.query(GOOGLE_DISCOVERY_PLUGIN_KEY, "getSourceConfig", { |
| @@ -676,16 +1340,11 @@ function GoogleDiscoveryDetailPage(props: { · 17 unmodified lines | |||
| 676 | 1340 | ||
| 677 | const config = configResult.value; | 1341 | const config = configResult.value; |
| 678 | return ( | 1342 | return ( |
| 679 | <div className="space-y-1"> | 1343 | <div className="space-y-1 text-xs text-muted-foreground"> |
| 680 | <div className="font-mono text-xs text-foreground"> | 1344 | <div className="text-foreground">{config.service} {config.version}</div> |
| 1345 | <div className="font-mono truncate"> | ||
| 681 | {config.discoveryUrl ?? defaultGoogleDiscoveryUrl(config.service, config.version)} | 1346 | {config.discoveryUrl ?? defaultGoogleDiscoveryUrl(config.service, config.version)} |
| 682 | </div> | 1347 | </div> |
| 683 | <div> | ||
| 684 | API: <span className="text-foreground">{config.service} {config.version}</span> | ||
| 685 | </div> | ||
| 686 | <div> | ||
| 687 | Auth: <span className="text-foreground">{config.auth.kind}</span> | ||
| 688 | </div> | ||
| 689 | </div> | 1348 | </div> |
| 690 | ); | 1349 | ); |
| 691 | }, [configResult]); | 1350 | }, [configResult]); |
| @@ -694,6 +1353,35 @@ function GoogleDiscoveryDetailPage(props: { · 2 unmodified lines | |||
| 694 | return <div className="text-sm text-muted-foreground">Loading workspace...</div>; | 1353 | return <div className="text-sm text-muted-foreground">Loading workspace...</div>; |
| 695 | } | 1354 | } |
| 696 | 1355 | ||
| 1356 | const handleDelete = async () => { | ||
| 1357 | if (isDeleting) { | ||
| 1358 | return; | ||
| 1359 | } | ||
| 1360 | |||
| 1361 | setIsDeleting(true); | ||
| 1362 | try { | ||
| 1363 | await removeSource({ | ||
| 1364 | path: { | ||
| 1365 | workspaceId: installation.data.scopeId, | ||
| 1366 | sourceId: props.source.id, | ||
| 1367 | }, | ||
| 1368 | reactivityKeys: { | ||
| 1369 | sources: [installation.data.scopeId], | ||
| 1370 | source: [installation.data.scopeId, props.source.id], | ||
| 1371 | sourceInspection: [installation.data.scopeId, props.source.id], | ||
| 1372 | sourceInspectionTool: [installation.data.scopeId, props.source.id], | ||
| 1373 | sourceDiscovery: [installation.data.scopeId, props.source.id], | ||
| 1374 | }, | ||
| 1375 | }); | ||
| 1376 | startTransition(() => { | ||
| 1377 | void navigation.home(); | ||
| 1378 | }); | ||
| 1379 | } finally { | ||
| 1380 | setIsDeleting(false); | ||
| 1381 | setConfirmDelete(false); | ||
| 1382 | } | ||
| 1383 | }; | ||
| 1384 | |||
| 697 | return ( | 1385 | return ( |
| 698 | <SourceToolExplorer | 1386 | <SourceToolExplorer |
| 699 | sourceId={props.source.id} | 1387 | sourceId={props.source.id} |
| @@ -713,38 +1401,40 @@ function GoogleDiscoveryDetailPage(props: { · 13 unmodified lines | |||
| 713 | <IconPencil className="size-3.5" /> | 1401 | <IconPencil className="size-3.5" /> |
| 714 | Edit | 1402 | Edit |
| 715 | </button> | 1403 | </button> |
| 716 | <button | 1404 | {confirmDelete ? ( |
| 717 | type="button" | 1405 | <div className="flex items-center gap-2"> |
| 718 | onClick={() => { | 1406 | <span className="text-xs font-medium text-destructive"> |
| 719 | const confirmed = window.confirm( | 1407 | Confirm delete? |
| 720 | `Delete Google Discovery source "${props.source.name}"?`, | 1408 | </span> |
| 721 | ); | 1409 | <button |
| 722 | if (!confirmed) { | 1410 | type="button" |
| 723 | return; | 1411 | onClick={() => setConfirmDelete(false)} |
| 724 | } | 1412 | disabled={isDeleting} |
| 725 | 1413 | className="inline-flex h-9 items-center rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50" | |
| 726 | void removeSource({ | 1414 | > |
| 727 | path: { | 1415 | Cancel |
| 728 | workspaceId: installation.data.scopeId, | 1416 | </button> |
| 729 | sourceId: props.source.id, | 1417 | <button |
| 730 | }, | 1418 | type="button" |
| 731 | reactivityKeys: { | 1419 | onClick={() => { |
| 732 | sources: [installation.data.scopeId], | 1420 | void handleDelete().catch(() => {}); |
| 733 | source: [installation.data.scopeId, props.source.id], | 1421 | }} |
| 734 | sourceInspection: [installation.data.scopeId, props.source.id], | 1422 | disabled={isDeleting} |
| 735 | sourceInspectionTool: [installation.data.scopeId, props.source.id], | 1423 | className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" |
| 736 | sourceDiscovery: [installation.data.scopeId, props.source.id], | 1424 | > |
| 737 | }, | 1425 | {isDeleting ? "Deleting..." : "Delete"} |
| 738 | }).then(() => { | 1426 | </button> |
| 739 | startTransition(() => { | 1427 | </div> |
| 740 | void navigation.home(); | 1428 | ) : ( |
| 741 | }); | 1429 | <button |
| 742 | }); | 1430 | type="button" |
| 743 | }} | 1431 | onClick={() => setConfirmDelete(true)} |
| 744 | className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10" | 1432 | disabled={isDeleting} |
| 745 | > | 1433 | className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" |
| 746 | Delete | 1434 | > |
| 747 | </button> | 1435 | Delete |
| 1436 | </button> | ||
| 1437 | )} | ||
| 748 | </> | 1438 | </> |
| 749 | )} | 1439 | )} |
| 750 | /> | 1440 | /> |
| @@ -755,7 +1445,7 @@ const googleDiscoverySourceType = defineFrontendSourceType({ · 4 unmodified lines | |||
| 755 | key: GOOGLE_DISCOVERY_PLUGIN_KEY, | 1445 | key: GOOGLE_DISCOVERY_PLUGIN_KEY, |
| 756 | kind: GOOGLE_DISCOVERY_SOURCE_KIND, | 1446 | kind: GOOGLE_DISCOVERY_SOURCE_KIND, |
| 757 | displayName: "Google Discovery", | 1447 | displayName: "Google Discovery", |
| 758 | description: "Import Google APIs from discovery documents with plugin-owned OAuth.", | 1448 | description: "Connect Google Workspace and Cloud APIs via discovery documents.", |
| 759 | getIconUrl: getGoogleDiscoveryIconUrl, | 1449 | getIconUrl: getGoogleDiscoveryIconUrl, |
| 760 | renderAddPage: GoogleDiscoveryAddPage, | 1450 | renderAddPage: GoogleDiscoveryAddPage, |
| 761 | renderEditPage: GoogleDiscoveryEditPage, | 1451 | renderEditPage: GoogleDiscoveryEditPage, |
| @@ -22,16 +22,19 @@ import { · 21 unmodified lines | |||
| 22 | import { | 22 | import { |
| 23 | GOOGLE_DISCOVERY_EXECUTOR_KEY, | 23 | GOOGLE_DISCOVERY_EXECUTOR_KEY, |
| 24 | GOOGLE_DISCOVERY_SOURCE_KIND, | 24 | GOOGLE_DISCOVERY_SOURCE_KIND, |
| 25 | GoogleDiscoveryBatchSourceInputSchema, | ||
| 25 | GoogleDiscoveryConnectionAuthSchema, | 26 | GoogleDiscoveryConnectionAuthSchema, |
| 26 | GoogleDiscoveryOAuthSessionSchema, | 27 | GoogleDiscoveryOAuthSessionSchema, |
| 27 | defaultGoogleDiscoveryUrl, | 28 | defaultGoogleDiscoveryUrl, |
| 28 | deriveGoogleDiscoveryNamespace, | 29 | deriveGoogleDiscoveryNamespace, |
| 29 | GoogleDiscoveryStoredSourceDataSchema, | 30 | GoogleDiscoveryStoredSourceDataSchema, |
| 31 | type GoogleDiscoveryBatchSourceInput, | ||
| 30 | type GoogleDiscoveryConnectInput, | 32 | type GoogleDiscoveryConnectInput, |
| 31 | type GoogleDiscoveryConnectionAuth, | 33 | type GoogleDiscoveryConnectionAuth, |
| 32 | type GoogleDiscoveryOAuthPopupResult, | 34 | type GoogleDiscoveryOAuthPopupResult, |
| 33 | type GoogleDiscoveryOAuthSession, | 35 | type GoogleDiscoveryOAuthSession, |
| 34 | type GoogleDiscoverySourceConfigPayload, | 36 | type GoogleDiscoverySourceConfigPayload, |
| 37 | type GoogleDiscoveryStartBatchOAuthInput, | ||
| 35 | type GoogleDiscoveryStartOAuthInput, | 38 | type GoogleDiscoveryStartOAuthInput, |
| 36 | type GoogleDiscoveryStartOAuthResult, | 39 | type GoogleDiscoveryStartOAuthResult, |
| 37 | type GoogleDiscoveryStoredSourceData, | 40 | type GoogleDiscoveryStoredSourceData, |
| @@ -64,6 +67,9 @@ import { · 26 unmodified lines | |||
| 64 | const decodeStoredSourceData = Schema.decodeUnknownSync( | 67 | const decodeStoredSourceData = Schema.decodeUnknownSync( |
| 65 | GoogleDiscoveryStoredSourceDataSchema, | 68 | GoogleDiscoveryStoredSourceDataSchema, |
| 66 | ); | 69 | ); |
| 70 | const decodeBatchSourceInput = Schema.decodeUnknownSync( | ||
| 71 | GoogleDiscoveryBatchSourceInputSchema, | ||
| 72 | ); | ||
| 67 | const decodeSession = Schema.decodeUnknownSync(GoogleDiscoveryOAuthSessionSchema); | 73 | const decodeSession = Schema.decodeUnknownSync(GoogleDiscoveryOAuthSessionSchema); |
| 68 | const decodeProviderData = Schema.decodeUnknownSync(GoogleDiscoveryToolProviderDataSchema); | 74 | const decodeProviderData = Schema.decodeUnknownSync(GoogleDiscoveryToolProviderDataSchema); |
| 69 | 75 | ||
| @@ -108,6 +114,9 @@ export type GoogleDiscoverySdk = { · 38 unmodified lines | |||
| 108 | startOAuth: ( | 114 | startOAuth: ( |
| 109 | input: GoogleDiscoveryStartOAuthInput, | 115 | input: GoogleDiscoveryStartOAuthInput, |
| 110 | ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>; | 116 | ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>; |
| 117 | startBatchOAuth: ( | ||
| 118 | input: GoogleDiscoveryStartBatchOAuthInput, | ||
| 119 | ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>; | ||
| 111 | completeOAuth: (input: { | 120 | completeOAuth: (input: { |
| 112 | state: string; | 121 | state: string; |
| 113 | code?: string; | 122 | code?: string; |
| @@ -129,6 +138,10 @@ const GoogleDiscoveryExecutorAddInputSchema = Schema.Struct({ · 15 unmodified lines | |||
| 129 | 138 | ||
| 130 | type GoogleDiscoveryExecutorAddInput = | 139 | type GoogleDiscoveryExecutorAddInput = |
| 131 | typeof GoogleDiscoveryExecutorAddInputSchema.Type; | 140 | typeof GoogleDiscoveryExecutorAddInputSchema.Type; |
| 141 | type GoogleDiscoveryOAuthAuth = Extract< | ||
| 142 | GoogleDiscoveryConnectionAuth, | ||
| 143 | { kind: "oauth2" } | ||
| 144 | >; | ||
| 132 | 145 | ||
| 133 | const GOOGLE_AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; | 146 | const GOOGLE_AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; |
| 134 | const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; | 147 | const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; |
| @@ -468,6 +481,70 @@ const sourceConfigFromStored = ( · 333 unmodified lines | |||
| 468 | auth: stored.auth, | 481 | auth: stored.auth, |
| 469 | }); | 482 | }); |
| 470 | 483 | ||
| 484 | const normalizeBatchSourceInput = ( | ||
| 485 | input: GoogleDiscoveryBatchSourceInput, | ||
| 486 | ): GoogleDiscoveryBatchSourceInput => | ||
| 487 | decodeBatchSourceInput({ | ||
| 488 | name: input.name.trim(), | ||
| 489 | service: input.service.trim(), | ||
| 490 | version: input.version.trim(), | ||
| 491 | discoveryUrl: | ||
| 492 | input.discoveryUrl?.trim() | ||
| 493 | || defaultGoogleDiscoveryUrl(input.service, input.version), | ||
| 494 | defaultHeaders: input.defaultHeaders, | ||
| 495 | scopes: [...new Set(input.scopes.map((scope) => scope.trim()).filter(Boolean))], | ||
| 496 | }); | ||
| 497 | |||
| 498 | const createOAuthSourceData = (input: { | ||
| 499 | service: string; | ||
| 500 | version: string; | ||
| 501 | discoveryUrl: string; | ||
| 502 | defaultHeaders: Record<string, string> | null; | ||
| 503 | scopes: ReadonlyArray<string>; | ||
| 504 | clientId: string; | ||
| 505 | clientSecretRef: GoogleDiscoveryOAuthAuth["clientSecretRef"]; | ||
| 506 | clientAuthentication: GoogleDiscoveryOAuthAuth["clientAuthentication"]; | ||
| 507 | accessTokenRef: GoogleDiscoveryOAuthAuth["accessTokenRef"]; | ||
| 508 | refreshTokenRef: GoogleDiscoveryOAuthAuth["refreshTokenRef"]; | ||
| 509 | expiresAt: number | null; | ||
| 510 | }): GoogleDiscoveryStoredSourceData => | ||
| 511 | decodeStoredSourceData({ | ||
| 512 | service: input.service, | ||
| 513 | version: input.version, | ||
| 514 | discoveryUrl: input.discoveryUrl, | ||
| 515 | defaultHeaders: input.defaultHeaders, | ||
| 516 | scopes: [...input.scopes], | ||
| 517 | auth: { | ||
| 518 | kind: "oauth2", | ||
| 519 | clientId: input.clientId, | ||
| 520 | clientSecretRef: input.clientSecretRef, | ||
| 521 | clientAuthentication: input.clientAuthentication, | ||
| 522 | authorizationEndpoint: GOOGLE_AUTHORIZATION_ENDPOINT, | ||
| 523 | tokenEndpoint: GOOGLE_TOKEN_ENDPOINT, | ||
| 524 | scopes: [...input.scopes], | ||
| 525 | accessTokenRef: input.accessTokenRef, | ||
| 526 | refreshTokenRef: input.refreshTokenRef, | ||
| 527 | expiresAt: input.expiresAt, | ||
| 528 | }, | ||
| 529 | }); | ||
| 530 | |||
| 531 | const resolveScopesForBatchSource = (input: { | ||
| 532 | source: GoogleDiscoveryBatchSourceInput; | ||
| 533 | }): Effect.Effect<ReadonlyArray<string>, Error> => | ||
| 534 | input.source.scopes.length > 0 | ||
| 535 | ? Effect.succeed([...input.source.scopes]) | ||
| 536 | : fetchGoogleDiscoveryDocumentWithHeaders({ | ||
| 537 | url: input.source.discoveryUrl ?? defaultGoogleDiscoveryUrl(input.source.service, input.source.version), | ||
| 538 | headers: input.source.defaultHeaders ?? {}, | ||
| 539 | }).pipe( | ||
| 540 | Effect.flatMap((document) => | ||
| 541 | inferScopesFromDiscoveryDocument({ | ||
| 542 | service: input.source.service, | ||
| 543 | document, | ||
| 544 | }) | ||
| 545 | ), | ||
| 546 | ); | ||
| 547 | |||
| 471 | const createGoogleDiscoverySourceSdk = ( | 548 | const createGoogleDiscoverySourceSdk = ( |
| 472 | options: { | 549 | options: { |
| 473 | storage: GoogleDiscoverySourceStorage; | 550 | storage: GoogleDiscoverySourceStorage; |
| @@ -801,6 +878,7 @@ export const googleDiscoverySdkPlugin = (options: { · 327 unmodified lines | |||
| 801 | yield* options.oauthSessions.put({ | 878 | yield* options.oauthSessions.put({ |
| 802 | sessionId, | 879 | sessionId, |
| 803 | value: decodeSession({ | 880 | value: decodeSession({ |
| 881 | kind: "single", | ||
| 804 | service: input.service.trim(), | 882 | service: input.service.trim(), |
| 805 | version: input.version.trim(), | 883 | version: input.version.trim(), |
| 806 | discoveryUrl, | 884 | discoveryUrl, |
| @@ -833,6 +911,65 @@ export const googleDiscoverySdkPlugin = (options: { · 26 unmodified lines | |||
| 833 | }; | 911 | }; |
| 834 | }), | 912 | }), |
| 835 | ), | 913 | ), |
| 914 | startBatchOAuth: (input) => | ||
| 915 | provideRuntime( | ||
| 916 | Effect.gen(function* () { | ||
| 917 | const normalizedSources = input.sources.map((source) => | ||
| 918 | normalizeBatchSourceInput(source) | ||
| 919 | ); | ||
| 920 | if (normalizedSources.length === 0) { | ||
| 921 | return yield* Effect.fail( | ||
| 922 | new Error("Select at least one Google API to connect."), | ||
| 923 | ); | ||
| 924 | } | ||
| 925 | |||
| 926 | const inferredScopes = yield* Effect.forEach( | ||
| 927 | normalizedSources, | ||
| 928 | (source) => | ||
| 929 | resolveScopesForBatchSource({ | ||
| 930 | source, | ||
| 931 | }), | ||
| 932 | ); | ||
| 933 | const scopes = [...new Set(inferredScopes.flat())]; | ||
| 934 | const sessionId = `gdisc_oauth_${crypto.randomUUID()}`; | ||
| 935 | const codeVerifier = createPkceCodeVerifier(); | ||
| 936 | |||
| 937 | yield* options.oauthSessions.put({ | ||
| 938 | sessionId, | ||
| 939 | value: decodeSession({ | ||
| 940 | kind: "batch", | ||
| 941 | sources: normalizedSources.map((source, index) => ({ | ||
| 942 | ...source, | ||
| 943 | scopes: inferredScopes[index] ?? [], | ||
| 944 | })), | ||
| 945 | scopes, | ||
| 946 | clientId: input.clientId.trim(), | ||
| 947 | clientSecretRef: input.clientSecretRef, | ||
| 948 | clientAuthentication: input.clientAuthentication, | ||
| 949 | redirectUrl: input.redirectUrl, | ||
| 950 | codeVerifier, | ||
| 951 | }), | ||
| 952 | }); | ||
| 953 | |||
| 954 | return { | ||
| 955 | sessionId, | ||
| 956 | authorizationUrl: buildOAuth2AuthorizationUrl({ | ||
| 957 | authorizationEndpoint: GOOGLE_AUTHORIZATION_ENDPOINT, | ||
| 958 | clientId: input.clientId.trim(), | ||
| 959 | redirectUri: input.redirectUrl, | ||
| 960 | scopes, | ||
| 961 | state: sessionId, | ||
| 962 | codeVerifier, | ||
| 963 | extraParams: { | ||
| 964 | access_type: "offline", | ||
| 965 | prompt: "consent", | ||
| 966 | include_granted_scopes: "true", | ||
| 967 | }, | ||
| 968 | }), | ||
| 969 | scopes, | ||
| 970 | }; | ||
| 971 | }), | ||
| 972 | ), | ||
| 836 | completeOAuth: (input) => | 973 | completeOAuth: (input) => |
| 837 | provideRuntime( | 974 | provideRuntime( |
| 838 | Effect.gen(function* () { | 975 | Effect.gen(function* () { |
| @@ -870,18 +1007,94 @@ export const googleDiscoverySdkPlugin = (options: { · 31 unmodified lines | |||
| 870 | codeVerifier: session.codeVerifier, | 1007 | codeVerifier: session.codeVerifier, |
| 871 | code: input.code, | 1008 | code: input.code, |
| 872 | }); | 1009 | }); |
| 873 | const accessTokenRef = yield* storeSecretMaterial({ | 1010 | const expiresAt = |
| 874 | purpose: "oauth_access_token", | 1011 | typeof tokenResponse.expires_in === "number" |
| 875 | value: tokenResponse.access_token, | 1012 | ? Date.now() + tokenResponse.expires_in * 1000 |
| 876 | name: `${session.service} Google Access Token`, | 1013 | : null; |
| 877 | }); | 1014 | |
| 878 | const refreshTokenRef = tokenResponse.refresh_token | 1015 | if (session.kind === "single") { |
| 879 | ? yield* storeSecretMaterial({ | 1016 | const accessTokenRef = yield* storeSecretMaterial({ |
| 880 | purpose: "oauth_refresh_token", | 1017 | purpose: "oauth_access_token", |
| 881 | value: tokenResponse.refresh_token, | 1018 | value: tokenResponse.access_token, |
| 882 | name: `${session.service} Google Refresh Token`, | 1019 | name: `${session.service} Google Access Token`, |
| 883 | }) | 1020 | }); |
| 884 | : null; | 1021 | const refreshTokenRef = tokenResponse.refresh_token |
| 1022 | ? yield* storeSecretMaterial({ | ||
| 1023 | purpose: "oauth_refresh_token", | ||
| 1024 | value: tokenResponse.refresh_token, | ||
| 1025 | name: `${session.service} Google Refresh Token`, | ||
| 1026 | }) | ||
| 1027 | : null; | ||
| 1028 | |||
| 1029 | if (options.oauthSessions.remove) { | ||
| 1030 | yield* options.oauthSessions.remove(input.state); | ||
| 1031 | } | ||
| 1032 | |||
| 1033 | return { | ||
| 1034 | type: "executor:oauth-result", | ||
| 1035 | ok: true as const, | ||
| 1036 | sessionId: input.state, | ||
| 1037 | mode: "single" as const, | ||
| 1038 | auth: { | ||
| 1039 | kind: "oauth2" as const, | ||
| 1040 | clientId: session.clientId, | ||
| 1041 | clientSecretRef: session.clientSecretRef, | ||
| 1042 | clientAuthentication: session.clientAuthentication, | ||
| 1043 | authorizationEndpoint: GOOGLE_AUTHORIZATION_ENDPOINT, | ||
| 1044 | tokenEndpoint: GOOGLE_TOKEN_ENDPOINT, | ||
| 1045 | scopes: [...session.scopes], | ||
| 1046 | accessTokenRef, | ||
| 1047 | refreshTokenRef, | ||
| 1048 | expiresAt, | ||
| 1049 | }, | ||
| 1050 | }; | ||
| 1051 | } | ||
| 1052 | |||
| 1053 | const createdSources = []; | ||
| 1054 | for (const source of session.sources) { | ||
| 1055 | const createdSource = yield* host.sources.create({ | ||
| 1056 | source: { | ||
| 1057 | name: source.name.trim(), | ||
| 1058 | kind: GOOGLE_DISCOVERY_SOURCE_KIND, | ||
| 1059 | status: "connected", | ||
| 1060 | enabled: true, | ||
| 1061 | namespace: deriveGoogleDiscoveryNamespace(source.service), | ||
| 1062 | }, | ||
| 1063 | }); | ||
| 1064 | const accessTokenRef = yield* storeSecretMaterial({ | ||
| 1065 | purpose: "oauth_access_token", | ||
| 1066 | value: tokenResponse.access_token, | ||
| 1067 | name: `${source.service} Google Access Token`, | ||
| 1068 | }); | ||
| 1069 | const refreshTokenRef = tokenResponse.refresh_token | ||
| 1070 | ? yield* storeSecretMaterial({ | ||
| 1071 | purpose: "oauth_refresh_token", | ||
| 1072 | value: tokenResponse.refresh_token, | ||
| 1073 | name: `${source.service} Google Refresh Token`, | ||
| 1074 | }) | ||
| 1075 | : null; | ||
| 1076 | |||
| 1077 | yield* options.storage.put({ | ||
| 1078 | scopeId: createdSource.scopeId, | ||
| 1079 | sourceId: createdSource.id, | ||
| 1080 | value: createOAuthSourceData({ | ||
| 1081 | service: source.service, | ||
| 1082 | version: source.version, | ||
| 1083 | discoveryUrl: source.discoveryUrl, | ||
| 1084 | defaultHeaders: source.defaultHeaders, | ||
| 1085 | scopes: source.scopes.length > 0 ? source.scopes : session.scopes, | ||
| 1086 | clientId: session.clientId, | ||
| 1087 | clientSecretRef: session.clientSecretRef, | ||
| 1088 | clientAuthentication: session.clientAuthentication, | ||
| 1089 | accessTokenRef, | ||
| 1090 | refreshTokenRef, | ||
| 1091 | expiresAt, | ||
| 1092 | }), | ||
| 1093 | }); | ||
| 1094 | |||
| 1095 | const refreshedSource = yield* host.sources.refreshCatalog(createdSource.id); | ||
| 1096 | createdSources.push(refreshedSource); | ||
| 1097 | } | ||
| 885 | 1098 | ||
| 886 | if (options.oauthSessions.remove) { | 1099 | if (options.oauthSessions.remove) { |
| 887 | yield* options.oauthSessions.remove(input.state); | 1100 | yield* options.oauthSessions.remove(input.state); |
| @@ -891,21 +1104,11 @@ export const googleDiscoverySdkPlugin = (options: { · 3 unmodified lines | |||
| 891 | type: "executor:oauth-result", | 1104 | type: "executor:oauth-result", |
| 892 | ok: true as const, | 1105 | ok: true as const, |
| 893 | sessionId: input.state, | 1106 | sessionId: input.state, |
| 894 | auth: { | 1107 | mode: "batch" as const, |
| 895 | kind: "oauth2" as const, | 1108 | sources: createdSources.map((source) => ({ |
| 896 | clientId: session.clientId, | 1109 | id: source.id, |
| 897 | clientSecretRef: session.clientSecretRef, | 1110 | name: source.name, |
| 898 | clientAuthentication: session.clientAuthentication, | 1111 | })), |
| 899 | authorizationEndpoint: GOOGLE_AUTHORIZATION_ENDPOINT, | ||
| 900 | tokenEndpoint: GOOGLE_TOKEN_ENDPOINT, | ||
| 901 | scopes: [...session.scopes], | ||
| 902 | accessTokenRef, | ||
| 903 | refreshTokenRef, | ||
| 904 | expiresAt: | ||
| 905 | typeof tokenResponse.expires_in === "number" | ||
| 906 | ? Date.now() + tokenResponse.expires_in * 1000 | ||
| 907 | : null, | ||
| 908 | }, | ||
| 909 | }; | 1112 | }; |
| 910 | }), | 1113 | }), |
| 911 | ), | 1114 | ), |
| @@ -79,18 +79,56 @@ export const GoogleDiscoveryStartOAuthInputSchema = Schema.Struct({ · 78 unmodified lines | |||
| 79 | redirectUrl: Schema.String, | 79 | redirectUrl: Schema.String, |
| 80 | }); | 80 | }); |
| 81 | 81 | ||
| 82 | export const GoogleDiscoveryBatchSourceInputSchema = Schema.Struct({ | ||
| 83 | name: Schema.String, | ||
| 84 | service: Schema.String, | ||
| 85 | version: Schema.String, | ||
| 86 | discoveryUrl: Schema.NullOr(Schema.String), | ||
| 87 | defaultHeaders: Schema.NullOr(StringMapSchema), | ||
| 88 | scopes: Schema.Array(Schema.String), | ||
| 89 | }); | ||
| 90 | |||
| 91 | export const GoogleDiscoveryStartBatchOAuthInputSchema = Schema.Struct({ | ||
| 92 | sources: Schema.Array(GoogleDiscoveryBatchSourceInputSchema), | ||
| 93 | clientId: Schema.String, | ||
| 94 | clientSecretRef: Schema.NullOr(SecretRefSchema), | ||
| 95 | clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema, | ||
| 96 | redirectUrl: Schema.String, | ||
| 97 | }); | ||
| 98 | |||
| 82 | export const GoogleDiscoveryStartOAuthResultSchema = Schema.Struct({ | 99 | export const GoogleDiscoveryStartOAuthResultSchema = Schema.Struct({ |
| 83 | sessionId: Schema.String, | 100 | sessionId: Schema.String, |
| 84 | authorizationUrl: Schema.String, | 101 | authorizationUrl: Schema.String, |
| 85 | scopes: Schema.Array(Schema.String), | 102 | scopes: Schema.Array(Schema.String), |
| 86 | }); | 103 | }); |
| 87 | 104 | ||
| 88 | export const GoogleDiscoveryOAuthSessionSchema = Schema.Struct({ | 105 | const GoogleDiscoverySingleOAuthSessionSchema = Schema.Struct({ |
| 106 | kind: Schema.Literal("single"), | ||
| 107 | service: Schema.String, | ||
| 108 | version: Schema.String, | ||
| 109 | discoveryUrl: Schema.String, | ||
| 110 | defaultHeaders: Schema.NullOr(StringMapSchema), | ||
| 111 | scopes: Schema.Array(Schema.String), | ||
| 112 | clientId: Schema.String, | ||
| 113 | clientSecretRef: Schema.NullOr(SecretRefSchema), | ||
| 114 | clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema, | ||
| 115 | redirectUrl: Schema.String, | ||
| 116 | codeVerifier: Schema.String, | ||
| 117 | }); | ||
| 118 | |||
| 119 | const GoogleDiscoveryBatchOAuthSessionSourceSchema = Schema.Struct({ | ||
| 120 | name: Schema.String, | ||
| 89 | service: Schema.String, | 121 | service: Schema.String, |
| 90 | version: Schema.String, | 122 | version: Schema.String, |
| 91 | discoveryUrl: Schema.String, | 123 | discoveryUrl: Schema.String, |
| 92 | defaultHeaders: Schema.NullOr(StringMapSchema), | 124 | defaultHeaders: Schema.NullOr(StringMapSchema), |
| 93 | scopes: Schema.Array(Schema.String), | 125 | scopes: Schema.Array(Schema.String), |
| 126 | }); | ||
| 127 | |||
| 128 | const GoogleDiscoveryBatchOAuthSessionSchema = Schema.Struct({ | ||
| 129 | kind: Schema.Literal("batch"), | ||
| 130 | sources: Schema.Array(GoogleDiscoveryBatchOAuthSessionSourceSchema), | ||
| 131 | scopes: Schema.Array(Schema.String), | ||
| 94 | clientId: Schema.String, | 132 | clientId: Schema.String, |
| 95 | clientSecretRef: Schema.NullOr(SecretRefSchema), | 133 | clientSecretRef: Schema.NullOr(SecretRefSchema), |
| 96 | clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema, | 134 | clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema, |
| @@ -98,6 +136,11 @@ export const GoogleDiscoveryOAuthSessionSchema = Schema.Struct({ · 1 unmodified line | |||
| 98 | codeVerifier: Schema.String, | 136 | codeVerifier: Schema.String, |
| 99 | }); | 137 | }); |
| 100 | 138 | ||
| 139 | export const GoogleDiscoveryOAuthSessionSchema = Schema.Union( | ||
| 140 | GoogleDiscoverySingleOAuthSessionSchema, | ||
| 141 | GoogleDiscoveryBatchOAuthSessionSchema, | ||
| 142 | ); | ||
| 143 | |||
| 101 | const GoogleDiscoveryOAuthPopupSuccessAuthSchema = Schema.Struct({ | 144 | const GoogleDiscoveryOAuthPopupSuccessAuthSchema = Schema.Struct({ |
| 102 | kind: Schema.Literal("oauth2"), | 145 | kind: Schema.Literal("oauth2"), |
| 103 | clientId: Schema.String, | 146 | clientId: Schema.String, |
| @@ -111,13 +154,26 @@ const GoogleDiscoveryOAuthPopupSuccessAuthSchema = Schema.Struct({ · 7 unmodified lines | |||
| 111 | expiresAt: Schema.NullOr(Schema.Number), | 154 | expiresAt: Schema.NullOr(Schema.Number), |
| 112 | }); | 155 | }); |
| 113 | 156 | ||
| 157 | const GoogleDiscoveryBatchOAuthPopupSourceSchema = Schema.Struct({ | ||
| 158 | id: Schema.String, | ||
| 159 | name: Schema.String, | ||
| 160 | }); | ||
| 161 | |||
| 114 | export const GoogleDiscoveryOAuthPopupResultSchema = Schema.Union( | 162 | export const GoogleDiscoveryOAuthPopupResultSchema = Schema.Union( |
| 115 | Schema.Struct({ | 163 | Schema.Struct({ |
| 116 | type: Schema.Literal("executor:oauth-result"), | 164 | type: Schema.Literal("executor:oauth-result"), |
| 117 | ok: Schema.Literal(true), | 165 | ok: Schema.Literal(true), |
| 118 | sessionId: Schema.String, | 166 | sessionId: Schema.String, |
| 167 | mode: Schema.Literal("single"), | ||
| 119 | auth: GoogleDiscoveryOAuthPopupSuccessAuthSchema, | 168 | auth: GoogleDiscoveryOAuthPopupSuccessAuthSchema, |
| 120 | }), | 169 | }), |
| 170 | Schema.Struct({ | ||
| 171 | type: Schema.Literal("executor:oauth-result"), | ||
| 172 | ok: Schema.Literal(true), | ||
| 173 | sessionId: Schema.String, | ||
| 174 | mode: Schema.Literal("batch"), | ||
| 175 | sources: Schema.Array(GoogleDiscoveryBatchOAuthPopupSourceSchema), | ||
| 176 | }), | ||
| 121 | Schema.Struct({ | 177 | Schema.Struct({ |
| 122 | type: Schema.Literal("executor:oauth-result"), | 178 | type: Schema.Literal("executor:oauth-result"), |
| 123 | ok: Schema.Literal(false), | 179 | ok: Schema.Literal(false), |
| @@ -140,6 +196,10 @@ export type GoogleDiscoveryStoredSourceData = · 16 unmodified lines | |||
| 140 | typeof GoogleDiscoveryStoredSourceDataSchema.Type; | 196 | typeof GoogleDiscoveryStoredSourceDataSchema.Type; |
| 141 | export type GoogleDiscoveryStartOAuthInput = | 197 | export type GoogleDiscoveryStartOAuthInput = |
| 142 | typeof GoogleDiscoveryStartOAuthInputSchema.Type; | 198 | typeof GoogleDiscoveryStartOAuthInputSchema.Type; |
| 199 | export type GoogleDiscoveryBatchSourceInput = | ||
| 200 | typeof GoogleDiscoveryBatchSourceInputSchema.Type; | ||
| 201 | export type GoogleDiscoveryStartBatchOAuthInput = | ||
| 202 | typeof GoogleDiscoveryStartBatchOAuthInputSchema.Type; | ||
| 143 | export type GoogleDiscoveryStartOAuthResult = | 203 | export type GoogleDiscoveryStartOAuthResult = |
| 144 | typeof GoogleDiscoveryStartOAuthResultSchema.Type; | 204 | typeof GoogleDiscoveryStartOAuthResultSchema.Type; |
| 145 | export type GoogleDiscoveryOAuthSession = | 205 | export type GoogleDiscoveryOAuthSession = |
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | |||
| 11 | Result, | 11 | Result, |
| 12 | useAtomSet, | 12 | useAtomSet, |
| 13 | useAtomValue, | 13 | useAtomValue, |
| 14 | useCreateSecret, | ||
| 14 | useExecutorMutation, | 15 | useExecutorMutation, |
| 15 | useLocalInstallation, | 16 | useLocalInstallation, |
| 16 | usePrefetchToolDetail, | 17 | usePrefetchToolDetail, |
| @@ -77,6 +78,53 @@ const defaultGraphqlInput = (): GraphqlConnectInput => ({ · 60 unmodified lines | |||
| 77 | }, | 78 | }, |
| 78 | }); | 79 | }); |
| 79 | 80 | ||
| 81 | const DEFAULT_BEARER_HEADER_NAME = "Authorization"; | ||
| 82 | const DEFAULT_BEARER_PREFIX = "Bearer "; | ||
| 83 | const CREATE_SECRET_VALUE = "__create_graphql_secret__"; | ||
| 84 | |||
| 85 | const presetString = ( | ||
| 86 | search: Record<string, unknown>, | ||
| 87 | key: string, | ||
| 88 | ): string | null => { | ||
| 89 | const value = search[key]; | ||
| 90 | return typeof value === "string" && value.trim().length > 0 | ||
| 91 | ? value.trim() | ||
| 92 | : null; | ||
| 93 | }; | ||
| 94 | |||
| 95 | const firstSearchString = ( | ||
| 96 | search: Record<string, unknown>, | ||
| 97 | keys: ReadonlyArray<string>, | ||
| 98 | ): string | null => { | ||
| 99 | for (const key of keys) { | ||
| 100 | const value = presetString(search, key); | ||
| 101 | if (value) { | ||
| 102 | return value; | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | return null; | ||
| 107 | }; | ||
| 108 | |||
| 109 | const graphqlInputFromSearch = ( | ||
| 110 | search: Record<string, unknown>, | ||
| 111 | ): GraphqlConnectInput => { | ||
| 112 | const defaults = defaultGraphqlInput(); | ||
| 113 | |||
| 114 | return { | ||
| 115 | ...defaults, | ||
| 116 | name: presetString(search, "presetName") ?? defaults.name, | ||
| 117 | endpoint: | ||
| 118 | firstSearchString(search, [ | ||
| 119 | "presetEndpoint", | ||
| 120 | "endpoint", | ||
| 121 | "inputUrl", | ||
| 122 | "pastedUrl", | ||
| 123 | "url", | ||
| 124 | ]) ?? defaults.endpoint, | ||
| 125 | }; | ||
| 126 | }; | ||
| 127 | |||
| 80 | const parseStringMap = (value: string): Record<string, string> | null => { | 128 | const parseStringMap = (value: string): Record<string, string> | null => { |
| 81 | const trimmed = value.trim(); | 129 | const trimmed = value.trim(); |
| 82 | if (!trimmed) { | 130 | if (!trimmed) { |
| @@ -108,9 +156,26 @@ const secretValue = (input: GraphqlConnectionAuth): string => · 25 unmodified lines | |||
| 108 | ? JSON.stringify(input.tokenSecretRef) | 156 | ? JSON.stringify(input.tokenSecretRef) |
| 109 | : ""; | 157 | : ""; |
| 110 | 158 | ||
| 159 | const bearerHeaderNameValue = (input: GraphqlConnectionAuth): string => | ||
| 160 | input.kind === "bearer" | ||
| 161 | ? input.headerName?.trim() || DEFAULT_BEARER_HEADER_NAME | ||
| 162 | : DEFAULT_BEARER_HEADER_NAME; | ||
| 163 | |||
| 164 | const bearerPrefixValue = (input: GraphqlConnectionAuth): string => | ||
| 165 | input.kind === "bearer" | ||
| 166 | ? input.prefix ?? DEFAULT_BEARER_PREFIX | ||
| 167 | : DEFAULT_BEARER_PREFIX; | ||
| 168 | |||
| 169 | const trimToNull = (value: string): string | null => { | ||
| 170 | const trimmed = value.trim(); | ||
| 171 | return trimmed.length > 0 ? trimmed : null; | ||
| 172 | }; | ||
| 173 | |||
| 111 | const authFromSecretValue = ( | 174 | const authFromSecretValue = ( |
| 112 | authKind: GraphqlConnectionAuth["kind"], | 175 | authKind: GraphqlConnectionAuth["kind"], |
| 113 | value: string, | 176 | value: string, |
| 177 | headerName: string, | ||
| 178 | prefix: string, | ||
| 114 | ): GraphqlConnectionAuth => { | 179 | ): GraphqlConnectionAuth => { |
| 115 | if (authKind === "none") { | 180 | if (authKind === "none") { |
| 116 | return { | 181 | return { |
| @@ -125,9 +190,139 @@ const authFromSecretValue = ( · 8 unmodified lines | |||
| 125 | return { | 190 | return { |
| 126 | kind: "bearer", | 191 | kind: "bearer", |
| 127 | tokenSecretRef: JSON.parse(value) as GraphqlConnectionAuth & { tokenSecretRef: never }["tokenSecretRef"], | 192 | tokenSecretRef: JSON.parse(value) as GraphqlConnectionAuth & { tokenSecretRef: never }["tokenSecretRef"], |
| 193 | headerName: trimToNull(headerName), | ||
| 194 | prefix: prefix === DEFAULT_BEARER_PREFIX ? null : prefix, | ||
| 128 | }; | 195 | }; |
| 129 | }; | 196 | }; |
| 130 | 197 | ||
| 198 | function SecretSelectOrCreateField(props: { | ||
| 199 | label: string; | ||
| 200 | value: string; | ||
| 201 | emptyLabel: string; | ||
| 202 | draftNamePlaceholder: string; | ||
| 203 | draftValuePlaceholder: string; | ||
| 204 | onChange: (value: string) => void; | ||
| 205 | }) { | ||
| 206 | const secrets = useSecrets(); | ||
| 207 | const createSecret = useCreateSecret(); | ||
| 208 | const [draftName, setDraftName] = useState(""); | ||
| 209 | const [draftValue, setDraftValue] = useState(""); | ||
| 210 | const [createError, setCreateError] = useState<string | null>(null); | ||
| 211 | const [showCreate, setShowCreate] = useState(false); | ||
| 212 | |||
| 213 | const handleSelectChange = (nextValue: string) => { | ||
| 214 | if (nextValue === CREATE_SECRET_VALUE) { | ||
| 215 | setShowCreate(true); | ||
| 216 | props.onChange(""); | ||
| 217 | return; | ||
| 218 | } | ||
| 219 | |||
| 220 | setShowCreate(false); | ||
| 221 | setCreateError(null); | ||
| 222 | props.onChange(nextValue); | ||
| 223 | }; | ||
| 224 | |||
| 225 | const handleCreate = async () => { | ||
| 226 | const trimmedName = draftName.trim(); | ||
| 227 | if (!trimmedName) { | ||
| 228 | setCreateError("Secret name is required."); | ||
| 229 | return; | ||
| 230 | } | ||
| 231 | if (!draftValue.trim()) { | ||
| 232 | setCreateError("Secret value is required."); | ||
| 233 | return; | ||
| 234 | } | ||
| 235 | |||
| 236 | try { | ||
| 237 | setCreateError(null); | ||
| 238 | const created = await createSecret.mutateAsync({ | ||
| 239 | name: trimmedName, | ||
| 240 | value: draftValue, | ||
| 241 | }); | ||
| 242 | props.onChange(JSON.stringify({ | ||
| 243 | providerId: created.providerId, | ||
| 244 | handle: created.id, | ||
| 245 | })); | ||
| 246 | setDraftName(""); | ||
| 247 | setDraftValue(""); | ||
| 248 | setShowCreate(false); | ||
| 249 | } catch (cause) { | ||
| 250 | setCreateError(cause instanceof Error ? cause.message : "Failed creating secret."); | ||
| 251 | } | ||
| 252 | }; | ||
| 253 | |||
| 254 | return ( | ||
| 255 | <div className="grid gap-2"> | ||
| 256 | <span className="text-xs font-medium text-foreground">{props.label}</span> | ||
| 257 | <select | ||
| 258 | value={showCreate ? CREATE_SECRET_VALUE : props.value} | ||
| 259 | onChange={(event) => handleSelectChange(event.target.value)} | ||
| 260 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 261 | > | ||
| 262 | <option value="">{props.emptyLabel}</option> | ||
| 263 | {secrets.status === "ready" && | ||
| 264 | secrets.data.map((secret) => ( | ||
| 265 | <option | ||
| 266 | key={`${secret.providerId}:${secret.id}`} | ||
| 267 | value={JSON.stringify({ | ||
| 268 | providerId: secret.providerId, | ||
| 269 | handle: secret.id, | ||
| 270 | })} | ||
| 271 | > | ||
| 272 | {secret.name ?? secret.id} | ||
| 273 | </option> | ||
| 274 | ))} | ||
| 275 | <option value={CREATE_SECRET_VALUE}>Create new secret</option> | ||
| 276 | </select> | ||
| 277 | |||
| 278 | {showCreate && ( | ||
| 279 | <div className="space-y-3 rounded-lg border border-border/70 bg-background/50 p-3"> | ||
| 280 | <input | ||
| 281 | value={draftName} | ||
| 282 | onChange={(event) => setDraftName(event.target.value)} | ||
| 283 | placeholder={props.draftNamePlaceholder} | ||
| 284 | className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 285 | /> | ||
| 286 | <textarea | ||
| 287 | value={draftValue} | ||
| 288 | onChange={(event) => setDraftValue(event.target.value)} | ||
| 289 | rows={3} | ||
| 290 | placeholder={props.draftValuePlaceholder} | ||
| 291 | className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 292 | /> | ||
| 293 | {createError && ( | ||
| 294 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive"> | ||
| 295 | {createError} | ||
| 296 | </div> | ||
| 297 | )} | ||
| 298 | <div className="flex items-center gap-2"> | ||
| 299 | <button | ||
| 300 | type="button" | ||
| 301 | onClick={() => { | ||
| 302 | void handleCreate(); | ||
| 303 | }} | ||
| 304 | disabled={createSecret.status === "pending"} | ||
| 305 | className="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50" | ||
| 306 | > | ||
| 307 | {createSecret.status === "pending" ? "Creating..." : "Create Secret"} | ||
| 308 | </button> | ||
| 309 | <button | ||
| 310 | type="button" | ||
| 311 | onClick={() => { | ||
| 312 | setShowCreate(false); | ||
| 313 | setCreateError(null); | ||
| 314 | }} | ||
| 315 | className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50" | ||
| 316 | > | ||
| 317 | Cancel | ||
| 318 | </button> | ||
| 319 | </div> | ||
| 320 | </div> | ||
| 321 | )} | ||
| 322 | </div> | ||
| 323 | ); | ||
| 324 | } | ||
| 325 | |||
| 131 | const graphqlToolRoutePath = (toolPath: string): string => | 326 | const graphqlToolRoutePath = (toolPath: string): string => |
| 132 | `tool/${encodeURIComponent(toolPath)}`; | 327 | `tool/${encodeURIComponent(toolPath)}`; |
| 133 | 328 | ||
| @@ -189,7 +384,6 @@ function GraphqlSourceForm(props: { · 55 unmodified lines | |||
| 189 | mode: "create" | "edit"; | 384 | mode: "create" | "edit"; |
| 190 | onSubmit: (input: GraphqlConnectInput) => Promise<void>; | 385 | onSubmit: (input: GraphqlConnectInput) => Promise<void>; |
| 191 | }) { | 386 | }) { |
| 192 | const secrets = useSecrets(); | ||
| 193 | const submitMutation = useExecutorMutation<GraphqlConnectInput, void>(props.onSubmit); | 387 | const submitMutation = useExecutorMutation<GraphqlConnectInput, void>(props.onSubmit); |
| 194 | const [name, setName] = useState(props.initialValue.name); | 388 | const [name, setName] = useState(props.initialValue.name); |
| 195 | const [endpoint, setEndpoint] = useState(props.initialValue.endpoint); | 389 | const [endpoint, setEndpoint] = useState(props.initialValue.endpoint); |
| @@ -200,94 +394,102 @@ function GraphqlSourceForm(props: { · 4 unmodified lines | |||
| 200 | props.initialValue.auth.kind, | 394 | props.initialValue.auth.kind, |
| 201 | ); | 395 | ); |
| 202 | const [secretRef, setSecretRef] = useState(secretValue(props.initialValue.auth)); | 396 | const [secretRef, setSecretRef] = useState(secretValue(props.initialValue.auth)); |
| 397 | const [authHeaderName, setAuthHeaderName] = useState( | ||
| 398 | bearerHeaderNameValue(props.initialValue.auth), | ||
| 399 | ); | ||
| 400 | const [authPrefix, setAuthPrefix] = useState( | ||
| 401 | bearerPrefixValue(props.initialValue.auth), | ||
| 402 | ); | ||
| 203 | const [error, setError] = useState<string | null>(null); | 403 | const [error, setError] = useState<string | null>(null); |
| 204 | 404 | ||
| 205 | return ( | 405 | return ( |
| 206 | <div className="space-y-4 rounded-2xl border border-border bg-card p-5"> | 406 | <div className="space-y-6 rounded-xl border border-border p-6"> |
| 207 | <div> | 407 | <div className="grid gap-4"> |
| 208 | <h2 className="text-lg font-semibold text-foreground"> | 408 | <label className="grid gap-2"> |
| 209 | {props.mode === "create" ? "Connect GraphQL Source" : "Edit GraphQL Source"} | 409 | <span className="text-xs font-medium text-foreground">Name</span> |
| 210 | </h2> | 410 | <input |
| 211 | <p className="mt-1 text-sm text-muted-foreground"> | 411 | value={name} |
| 212 | GraphQL is now mounted as a plugin. The source record stays generic; endpoint and auth | 412 | onChange={(event) => setName(event.target.value)} |
| 213 | live entirely in plugin storage. | 413 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 214 | </p> | 414 | /> |
| 215 | </div> | 415 | </label> |
| 216 | 416 | ||
| 217 | <label className="grid gap-1.5"> | 417 | <label className="grid gap-2"> |
| 218 | <span className="text-xs font-medium text-foreground">Name</span> | 418 | <span className="text-xs font-medium text-foreground">Endpoint</span> |
| 219 | <input | 419 | <input |
| 220 | value={name} | 420 | value={endpoint} |
| 221 | onChange={(event) => setName(event.target.value)} | 421 | onChange={(event) => setEndpoint(event.target.value)} |
| 222 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 422 | className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 223 | /> | 423 | /> |
| 224 | </label> | 424 | </label> |
| 225 | 425 | ||
| 226 | <label className="grid gap-1.5"> | 426 | <label className="grid gap-2"> |
| 227 | <span className="text-xs font-medium text-foreground">Endpoint</span> | 427 | <span className="text-xs font-medium text-foreground">Default Headers</span> |
| 228 | <input | 428 | <textarea |
| 229 | value={endpoint} | 429 | value={headersText} |
| 230 | onChange={(event) => setEndpoint(event.target.value)} | 430 | onChange={(event) => setHeadersText(event.target.value)} |
| 231 | className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 431 | rows={3} |
| 232 | /> | 432 | placeholder='{"x-api-version":"2026-03-23"}' |
| 233 | </label> | 433 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 234 | 434 | /> | |
| 235 | <label className="grid gap-1.5"> | 435 | </label> |
| 236 | <span className="text-xs font-medium text-foreground">Default Headers</span> | 436 | |
| 237 | <textarea | 437 | <label className="grid gap-2"> |
| 238 | value={headersText} | 438 | <span className="text-xs font-medium text-foreground">Auth</span> |
| 239 | onChange={(event) => setHeadersText(event.target.value)} | ||
| 240 | rows={5} | ||
| 241 | placeholder='{"x-api-version":"2026-03-23"}' | ||
| 242 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 243 | /> | ||
| 244 | </label> | ||
| 245 | |||
| 246 | <label className="grid gap-1.5"> | ||
| 247 | <span className="text-xs font-medium text-foreground">Auth</span> | ||
| 248 | <select | ||
| 249 | value={authKind} | ||
| 250 | onChange={(event) => | ||
| 251 | setAuthKind(event.target.value as GraphqlConnectionAuth["kind"])} | ||
| 252 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 253 | > | ||
| 254 | <option value="none">None</option> | ||
| 255 | <option value="bearer">Bearer Secret</option> | ||
| 256 | </select> | ||
| 257 | </label> | ||
| 258 | |||
| 259 | {authKind === "bearer" && ( | ||
| 260 | <label className="grid gap-1.5"> | ||
| 261 | <span className="text-xs font-medium text-foreground">Secret</span> | ||
| 262 | <select | 439 | <select |
| 263 | value={secretRef} | 440 | value={authKind} |
| 264 | onChange={(event) => setSecretRef(event.target.value)} | 441 | onChange={(event) => |
| 265 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 442 | setAuthKind(event.target.value as GraphqlConnectionAuth["kind"])} |
| 443 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 266 | > | 444 | > |
| 267 | <option value="">Select a secret</option> | 445 | <option value="none">None</option> |
| 268 | {secrets.status === "ready" && | 446 | <option value="bearer">Bearer Secret</option> |
| 269 | secrets.data.map((secret) => ( | ||
| 270 | <option | ||
| 271 | key={`${secret.providerId}:${secret.id}`} | ||
| 272 | value={JSON.stringify({ | ||
| 273 | providerId: secret.providerId, | ||
| 274 | handle: secret.id, | ||
| 275 | })} | ||
| 276 | > | ||
| 277 | {secret.name ?? secret.id} | ||
| 278 | </option> | ||
| 279 | ))} | ||
| 280 | </select> | 447 | </select> |
| 281 | </label> | 448 | </label> |
| 282 | )} | 449 | |
| 450 | {authKind === "bearer" && ( | ||
| 451 | <div className="space-y-4 border-l-2 border-border pl-4"> | ||
| 452 | <SecretSelectOrCreateField | ||
| 453 | label="Secret" | ||
| 454 | value={secretRef} | ||
| 455 | emptyLabel="Select a secret" | ||
| 456 | draftNamePlaceholder="GraphQL bearer token" | ||
| 457 | draftValuePlaceholder="Paste the token value" | ||
| 458 | onChange={setSecretRef} | ||
| 459 | /> | ||
| 460 | |||
| 461 | <div className="grid gap-4 sm:grid-cols-2"> | ||
| 462 | <label className="grid gap-2"> | ||
| 463 | <span className="text-xs font-medium text-foreground">Header Name</span> | ||
| 464 | <input | ||
| 465 | value={authHeaderName} | ||
| 466 | onChange={(event) => setAuthHeaderName(event.target.value)} | ||
| 467 | placeholder={DEFAULT_BEARER_HEADER_NAME} | ||
| 468 | className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 469 | /> | ||
| 470 | </label> | ||
| 471 | |||
| 472 | <label className="grid gap-2"> | ||
| 473 | <span className="text-xs font-medium text-foreground">Prefix</span> | ||
| 474 | <input | ||
| 475 | value={authPrefix} | ||
| 476 | onChange={(event) => setAuthPrefix(event.target.value)} | ||
| 477 | placeholder={DEFAULT_BEARER_PREFIX} | ||
| 478 | className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 479 | /> | ||
| 480 | </label> | ||
| 481 | </div> | ||
| 482 | </div> | ||
| 483 | )} | ||
| 484 | </div> | ||
| 283 | 485 | ||
| 284 | {error && ( | 486 | {error && ( |
| 285 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> | 487 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive"> |
| 286 | {error} | 488 | {error} |
| 287 | </div> | 489 | </div> |
| 288 | )} | 490 | )} |
| 289 | 491 | ||
| 290 | <div className="flex items-center gap-3"> | 492 | <div className="flex items-center justify-end"> |
| 291 | <button | 493 | <button |
| 292 | type="button" | 494 | type="button" |
| 293 | onClick={() => { | 495 | onClick={() => { |
| @@ -298,7 +500,12 @@ function GraphqlSourceForm(props: { · 4 unmodified lines | |||
| 298 | name: name.trim(), | 500 | name: name.trim(), |
| 299 | endpoint: endpoint.trim(), | 501 | endpoint: endpoint.trim(), |
| 300 | defaultHeaders: parseStringMap(headersText), | 502 | defaultHeaders: parseStringMap(headersText), |
| 301 | auth: authFromSecretValue(authKind, secretRef), | 503 | auth: authFromSecretValue( |
| 504 | authKind, | ||
| 505 | secretRef, | ||
| 506 | authHeaderName, | ||
| 507 | authPrefix, | ||
| 508 | ), | ||
| 302 | }); | 509 | }); |
| 303 | } catch (cause) { | 510 | } catch (cause) { |
| 304 | setError(cause instanceof Error ? cause.message : "Failed saving GraphQL source."); | 511 | setError(cause instanceof Error ? cause.message : "Failed saving GraphQL source."); |
| @@ -306,17 +513,12 @@ function GraphqlSourceForm(props: { · 1 unmodified line | |||
| 306 | })(); | 513 | })(); |
| 307 | }} | 514 | }} |
| 308 | disabled={submitMutation.status === "pending"} | 515 | disabled={submitMutation.status === "pending"} |
| 309 | className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50" | 516 | className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50" |
| 310 | > | 517 | > |
| 311 | {submitMutation.status === "pending" | 518 | {submitMutation.status === "pending" |
| 312 | ? props.mode === "create" ? "Creating..." : "Saving..." | 519 | ? props.mode === "create" ? "Creating..." : "Saving..." |
| 313 | : props.mode === "create" ? "Create Source" : "Save Changes"} | 520 | : props.mode === "create" ? "Create Source" : "Save Changes"} |
| 314 | </button> | 521 | </button> |
| 315 | <div className="text-xs text-muted-foreground"> | ||
| 316 | {props.mode === "create" | ||
| 317 | ? "The plugin will introspect the endpoint immediately after creation." | ||
| 318 | : "Saving refreshes the imported schema and tool catalog."} | ||
| 319 | </div> | ||
| 320 | </div> | 522 | </div> |
| 321 | </div> | 523 | </div> |
| 322 | ); | 524 | ); |
| @@ -324,6 +526,7 @@ function GraphqlSourceForm(props: { · 1 unmodified line | |||
| 324 | 526 | ||
| 325 | function GraphqlAddPage() { | 527 | function GraphqlAddPage() { |
| 326 | const navigation = useSourcePluginNavigation(); | 528 | const navigation = useSourcePluginNavigation(); |
| 529 | const initialValue = graphqlInputFromSearch(useSourcePluginSearch()); | ||
| 327 | const installation = useLocalInstallation(); | 530 | const installation = useLocalInstallation(); |
| 328 | const client = getGraphqlHttpClient(); | 531 | const client = getGraphqlHttpClient(); |
| 329 | const createSource = useAtomSet( | 532 | const createSource = useAtomSet( |
| @@ -337,7 +540,7 @@ function GraphqlAddPage() { · 7 unmodified lines | |||
| 337 | 540 | ||
| 338 | return ( | 541 | return ( |
| 339 | <GraphqlSourceForm | 542 | <GraphqlSourceForm |
| 340 | initialValue={defaultGraphqlInput()} | 543 | initialValue={initialValue} |
| 341 | mode="create" | 544 | mode="create" |
| 342 | onSubmit={async (input) => { | 545 | onSubmit={async (input) => { |
| 343 | const source = await createSource({ | 546 | const source = await createSource({ |
| @@ -394,13 +597,13 @@ function GraphqlEditPage(props: { · 50 unmodified lines | |||
| 394 | if (Result.isFailure(configResult)) { | 597 | if (Result.isFailure(configResult)) { |
| 395 | return ( | 598 | return ( |
| 396 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> | 599 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> |
| 397 | Failed loading GraphQL config. | 600 | Failed loading source configuration. |
| 398 | </div> | 601 | </div> |
| 399 | ); | 602 | ); |
| 400 | } | 603 | } |
| 401 | 604 | ||
| 402 | if (!Result.isSuccess(configResult)) { | 605 | if (!Result.isSuccess(configResult)) { |
| 403 | return <div className="text-sm text-muted-foreground">Loading GraphQL config...</div>; | 606 | return <div className="text-sm text-muted-foreground">Loading configuration...</div>; |
| 404 | } | 607 | } |
| 405 | 608 | ||
| 406 | return ( | 609 | return ( |
| @@ -479,6 +682,8 @@ function GraphqlDetailExplorer(props: { · 72 unmodified lines | |||
| 479 | props.source.id, | 682 | props.source.id, |
| 480 | tab === "model" ? selectedToolPath : null, | 683 | tab === "model" ? selectedToolPath : null, |
| 481 | ); | 684 | ); |
| 685 | const [confirmDelete, setConfirmDelete] = useState(false); | ||
| 686 | const [isDeleting, setIsDeleting] = useState(false); | ||
| 482 | 687 | ||
| 483 | if (installation.status !== "ready") { | 688 | if (installation.status !== "ready") { |
| 484 | return <div className="text-sm text-muted-foreground">Loading workspace...</div>; | 689 | return <div className="text-sm text-muted-foreground">Loading workspace...</div>; |
| @@ -545,6 +750,35 @@ function GraphqlDetailExplorer(props: { · 60 unmodified lines | |||
| 545 | return navigation.detail(props.source.id, searchState); | 750 | return navigation.detail(props.source.id, searchState); |
| 546 | }; | 751 | }; |
| 547 | 752 | ||
| 753 | const handleDelete = async () => { | ||
| 754 | if (isDeleting) { | ||
| 755 | return; | ||
| 756 | } | ||
| 757 | |||
| 758 | setIsDeleting(true); | ||
| 759 | try { | ||
| 760 | await removeSource({ | ||
| 761 | path: { | ||
| 762 | workspaceId: installation.data.scopeId, | ||
| 763 | sourceId: props.source.id, | ||
| 764 | }, | ||
| 765 | reactivityKeys: { | ||
| 766 | sources: [installation.data.scopeId], | ||
| 767 | source: [installation.data.scopeId, props.source.id], | ||
| 768 | sourceInspection: [installation.data.scopeId, props.source.id], | ||
| 769 | sourceInspectionTool: [installation.data.scopeId, props.source.id], | ||
| 770 | sourceDiscovery: [installation.data.scopeId, props.source.id], | ||
| 771 | }, | ||
| 772 | }); | ||
| 773 | startTransition(() => { | ||
| 774 | void navigation.home(); | ||
| 775 | }); | ||
| 776 | } finally { | ||
| 777 | setIsDeleting(false); | ||
| 778 | setConfirmDelete(false); | ||
| 779 | } | ||
| 780 | }; | ||
| 781 | |||
| 548 | return ( | 782 | return ( |
| 549 | <LoadableBlock loadable={inspection} loading="Loading source..."> | 783 | <LoadableBlock loadable={inspection} loading="Loading source..."> |
| 550 | {(loadedInspection) => { | 784 | {(loadedInspection) => { |
| @@ -594,36 +828,40 @@ function GraphqlDetailExplorer(props: { · 43 unmodified lines | |||
| 594 | <IconPencil className="size-3" /> | 828 | <IconPencil className="size-3" /> |
| 595 | Edit | 829 | Edit |
| 596 | </button> | 830 | </button> |
| 597 | <button | 831 | {confirmDelete ? ( |
| 598 | type="button" | 832 | <div className="flex items-center gap-2"> |
| 599 | onClick={() => { | 833 | <span className="text-[11px] font-medium text-destructive"> |
| 600 | const confirmed = window.confirm(`Delete GraphQL source "${props.source.name}"?`); | 834 | Confirm delete? |
| 601 | if (!confirmed) { | 835 | </span> |
| 602 | return; | 836 | <button |
| 603 | } | 837 | type="button" |
| 604 | 838 | onClick={() => setConfirmDelete(false)} | |
| 605 | void removeSource({ | 839 | disabled={isDeleting} |
| 606 | path: { | 840 | className="inline-flex items-center rounded-md border border-border bg-card px-2.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:pointer-events-none disabled:opacity-50" |
| 607 | workspaceId: installation.data.scopeId, | 841 | > |
| 608 | sourceId: props.source.id, | 842 | Cancel |
| 609 | }, | 843 | </button> |
| 610 | reactivityKeys: { | 844 | <button |
| 611 | sources: [installation.data.scopeId], | 845 | type="button" |
| 612 | source: [installation.data.scopeId, props.source.id], | 846 | onClick={() => { |
| 613 | sourceInspection: [installation.data.scopeId, props.source.id], | 847 | void handleDelete().catch(() => {}); |
| 614 | sourceInspectionTool: [installation.data.scopeId, props.source.id], | 848 | }} |
| 615 | sourceDiscovery: [installation.data.scopeId, props.source.id], | 849 | disabled={isDeleting} |
| 616 | }, | 850 | className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" |
| 617 | }).then(() => { | 851 | > |
| 618 | startTransition(() => { | 852 | {isDeleting ? "Deleting..." : "Delete"} |
| 619 | void navigation.home(); | 853 | </button> |
| 620 | }); | 854 | </div> |
| 621 | }); | 855 | ) : ( |
| 622 | }} | 856 | <button |
| 623 | className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10" | 857 | type="button" |
| 624 | > | 858 | onClick={() => setConfirmDelete(true)} |
| 625 | Delete | 859 | disabled={isDeleting} |
| 626 | </button> | 860 | className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" |
| 861 | > | ||
| 862 | Delete | ||
| 863 | </button> | ||
| 864 | )} | ||
| 627 | </div> | 865 | </div> |
| 628 | </div> | 866 | </div> |
| 629 | 867 | ||
| @@ -90,6 +90,37 @@ const GraphqlExecutorAddInputSchema = Schema.Struct({ · 89 unmodified lines | |||
| 90 | 90 | ||
| 91 | type GraphqlExecutorAddInput = typeof GraphqlExecutorAddInputSchema.Type; | 91 | type GraphqlExecutorAddInput = typeof GraphqlExecutorAddInputSchema.Type; |
| 92 | 92 | ||
| 93 | const normalizeGraphqlAuth = ( | ||
| 94 | auth: Record<string, unknown>, | ||
| 95 | ): GraphqlStoredSourceData["auth"] => | ||
| 96 | auth.kind === "bearer" | ||
| 97 | ? { | ||
| 98 | kind: "bearer", | ||
| 99 | tokenSecretRef: auth.tokenSecretRef as Extract< | ||
| 100 | GraphqlStoredSourceData["auth"], | ||
| 101 | { kind: "bearer" } | ||
| 102 | >["tokenSecretRef"], | ||
| 103 | headerName: | ||
| 104 | typeof auth.headerName === "string" || auth.headerName === null | ||
| 105 | ? auth.headerName | ||
| 106 | : null, | ||
| 107 | prefix: | ||
| 108 | typeof auth.prefix === "string" || auth.prefix === null | ||
| 109 | ? auth.prefix | ||
| 110 | : null, | ||
| 111 | } | ||
| 112 | : { | ||
| 113 | kind: "none", | ||
| 114 | }; | ||
| 115 | |||
| 116 | const normalizeStoredSourceData = ( | ||
| 117 | stored: GraphqlStoredSourceData, | ||
| 118 | ): GraphqlStoredSourceData => | ||
| 119 | decodeStoredSourceData({ | ||
| 120 | ...stored, | ||
| 121 | auth: normalizeGraphqlAuth(stored.auth as Record<string, unknown>), | ||
| 122 | }); | ||
| 123 | |||
| 93 | const asRecord = (value: unknown): Record<string, unknown> => | 124 | const asRecord = (value: unknown): Record<string, unknown> => |
| 94 | typeof value === "object" && value !== null && !Array.isArray(value) | 125 | typeof value === "object" && value !== null && !Array.isArray(value) |
| 95 | ? (value as Record<string, unknown>) | 126 | ? (value as Record<string, unknown>) |
| @@ -142,6 +173,17 @@ const resolveBearerToken = ( · 46 unmodified lines | |||
| 142 | }).pipe(Effect.map((token) => token.trim())) | 173 | }).pipe(Effect.map((token) => token.trim())) |
| 143 | ); | 174 | ); |
| 144 | 175 | ||
| 176 | const resolveBearerHeaderName = ( | ||
| 177 | auth: Extract<GraphqlStoredSourceData["auth"], { kind: "bearer" }>, | ||
| 178 | ): string => { | ||
| 179 | const trimmed = auth.headerName?.trim(); | ||
| 180 | return trimmed && trimmed.length > 0 ? trimmed : "Authorization"; | ||
| 181 | }; | ||
| 182 | |||
| 183 | const resolveBearerPrefix = ( | ||
| 184 | auth: Extract<GraphqlStoredSourceData["auth"], { kind: "bearer" }>, | ||
| 185 | ): string => auth.prefix ?? "Bearer "; | ||
| 186 | |||
| 145 | const resolveGraphqlHeaders = ( | 187 | const resolveGraphqlHeaders = ( |
| 146 | stored: GraphqlStoredSourceData, | 188 | stored: GraphqlStoredSourceData, |
| 147 | ): Effect.Effect<Record<string, string>, Error, any> => | 189 | ): Effect.Effect<Record<string, string>, Error, any> => |
| @@ -149,9 +191,10 @@ const resolveGraphqlHeaders = ( · 1 unmodified line | |||
| 149 | const bearerToken = yield* resolveBearerToken(stored.auth); | 191 | const bearerToken = yield* resolveBearerToken(stored.auth); |
| 150 | return { | 192 | return { |
| 151 | ...(stored.defaultHeaders ?? {}), | 193 | ...(stored.defaultHeaders ?? {}), |
| 152 | ...(bearerToken | 194 | ...(bearerToken && stored.auth.kind === "bearer" |
| 153 | ? { | 195 | ? { |
| 154 | authorization: `Bearer ${bearerToken}`, | 196 | [resolveBearerHeaderName(stored.auth)]: |
| 197 | `${resolveBearerPrefix(stored.auth)}${bearerToken}`, | ||
| 155 | } | 198 | } |
| 156 | : {}), | 199 | : {}), |
| 157 | }; | 200 | }; |
| @@ -241,7 +284,14 @@ const storedSourceDataFromInput = ( · 83 unmodified lines | |||
| 241 | decodeStoredSourceData({ | 284 | decodeStoredSourceData({ |
| 242 | endpoint: input.endpoint.trim(), | 285 | endpoint: input.endpoint.trim(), |
| 243 | defaultHeaders: input.defaultHeaders, | 286 | defaultHeaders: input.defaultHeaders, |
| 244 | auth: input.auth, | 287 | auth: |
| 288 | input.auth.kind === "bearer" | ||
| 289 | ? { | ||
| 290 | ...input.auth, | ||
| 291 | headerName: input.auth.headerName ?? null, | ||
| 292 | prefix: input.auth.prefix ?? null, | ||
| 293 | } | ||
| 294 | : input.auth, | ||
| 245 | }); | 295 | }); |
| 246 | 296 | ||
| 247 | const sourceConfigFromStored = ( | 297 | const sourceConfigFromStored = ( |
| @@ -279,7 +329,10 @@ const createGraphqlSourceSdk = ( · 31 unmodified lines | |||
| 279 | ); | 329 | ); |
| 280 | } | 330 | } |
| 281 | 331 | ||
| 282 | return sourceConfigFromStored(source, stored); | 332 | return sourceConfigFromStored( |
| 333 | source, | ||
| 334 | normalizeStoredSourceData(stored), | ||
| 335 | ); | ||
| 283 | }), | 336 | }), |
| 284 | createSource: (input: GraphqlConnectInput) => | 337 | createSource: (input: GraphqlConnectInput) => |
| 285 | Effect.gen(function* () { | 338 | Effect.gen(function* () { |
| @@ -404,9 +457,10 @@ const createGraphqlSourceRuntime = ( · 118 unmodified lines | |||
| 404 | }); | 457 | }); |
| 405 | } | 458 | } |
| 406 | 459 | ||
| 407 | const headers = yield* resolveGraphqlHeaders(stored); | 460 | const normalizedStored = normalizeStoredSourceData(stored); |
| 461 | const headers = yield* resolveGraphqlHeaders(normalizedStored); | ||
| 408 | const graphqlDocument = yield* fetchGraphqlIntrospectionDocumentWithHeaders({ | 462 | const graphqlDocument = yield* fetchGraphqlIntrospectionDocumentWithHeaders({ |
| 409 | endpoint: stored.endpoint, | 463 | endpoint: normalizedStored.endpoint, |
| 410 | headers, | 464 | headers, |
| 411 | }); | 465 | }); |
| 412 | const manifest = yield* extractGraphqlManifest(source.name, graphqlDocument); | 466 | const manifest = yield* extractGraphqlManifest(source.name, graphqlDocument); |
| @@ -427,7 +481,7 @@ const createGraphqlSourceRuntime = ( · 14 unmodified lines | |||
| 427 | documents: [ | 481 | documents: [ |
| 428 | { | 482 | { |
| 429 | documentKind: "graphql_introspection", | 483 | documentKind: "graphql_introspection", |
| 430 | documentKey: stored.endpoint, | 484 | documentKey: normalizedStored.endpoint, |
| 431 | contentText: graphqlDocument, | 485 | contentText: graphqlDocument, |
| 432 | fetchedAt: now, | 486 | fetchedAt: now, |
| 433 | }, | 487 | }, |
| @@ -456,17 +510,18 @@ const createGraphqlSourceRuntime = ( · 22 unmodified lines | |||
| 456 | ); | 510 | ); |
| 457 | } | 511 | } |
| 458 | 512 | ||
| 513 | const normalizedStored = normalizeStoredSourceData(stored); | ||
| 459 | const providerData = decodeProviderData( | 514 | const providerData = decodeProviderData( |
| 460 | input.executable.binding, | 515 | input.executable.binding, |
| 461 | ) as GraphqlToolProviderData; | 516 | ) as GraphqlToolProviderData; |
| 462 | const args = asRecord(input.args); | 517 | const args = asRecord(input.args); |
| 463 | const headers = { | 518 | const headers = { |
| 464 | "content-type": "application/json", | 519 | "content-type": "application/json", |
| 465 | ...(yield* resolveGraphqlHeaders(stored)), | 520 | ...(yield* resolveGraphqlHeaders(normalizedStored)), |
| 466 | ...asStringRecord(args.headers), | 521 | ...asStringRecord(args.headers), |
| 467 | }; | 522 | }; |
| 468 | const endpoint = applyHttpQueryPlacementsToUrl({ | 523 | const endpoint = applyHttpQueryPlacementsToUrl({ |
| 469 | url: stored.endpoint, | 524 | url: normalizedStored.endpoint, |
| 470 | }).toString(); | 525 | }).toString(); |
| 471 | 526 | ||
| 472 | const isRawRequest = | 527 | const isRawRequest = |
| @@ -511,7 +566,7 @@ const createGraphqlSourceRuntime = ( · 38 unmodified lines | |||
| 511 | ...(operationName ? { operationName } : {}), | 566 | ...(operationName ? { operationName } : {}), |
| 512 | }, | 567 | }, |
| 513 | bodyValues: {}, | 568 | bodyValues: {}, |
| 514 | label: `GraphQL invocation ${stored.endpoint}`, | 569 | label: `GraphQL invocation ${normalizedStored.endpoint}`, |
| 515 | }), | 570 | }), |
| 516 | ), | 571 | ), |
| 517 | }), | 572 | }), |
| @@ -16,6 +16,8 @@ export const GraphqlConnectionAuthSchema = Schema.Union( · 15 unmodified lines | |||
| 16 | Schema.Struct({ | 16 | Schema.Struct({ |
| 17 | kind: Schema.Literal("bearer"), | 17 | kind: Schema.Literal("bearer"), |
| 18 | tokenSecretRef: SecretRefSchema, | 18 | tokenSecretRef: SecretRefSchema, |
| 19 | headerName: Schema.NullOr(Schema.String), | ||
| 20 | prefix: Schema.NullOr(Schema.String), | ||
| 19 | }), | 21 | }), |
| 20 | ); | 22 | ); |
| 21 | 23 | ||
| @@ -26,11 +26,15 @@ import * as Schema from "effect/Schema"; · 25 unmodified lines | |||
| 26 | 26 | ||
| 27 | import { | 27 | import { |
| 28 | McpConnectInputSchema, | 28 | McpConnectInputSchema, |
| 29 | McpDiscoverInputSchema, | ||
| 30 | McpDiscoverResultSchema, | ||
| 29 | McpOAuthPopupResultSchema, | 31 | McpOAuthPopupResultSchema, |
| 30 | McpSourceConfigPayloadSchema, | 32 | McpSourceConfigPayloadSchema, |
| 31 | McpStartOAuthInputSchema, | 33 | McpStartOAuthInputSchema, |
| 32 | McpStartOAuthResultSchema, | 34 | McpStartOAuthResultSchema, |
| 33 | type McpConnectInput, | 35 | type McpConnectInput, |
| 36 | type McpDiscoverInput, | ||
| 37 | type McpDiscoverResult, | ||
| 34 | type McpOAuthPopupResult, | 38 | type McpOAuthPopupResult, |
| 35 | type McpSourceConfigPayload, | 39 | type McpSourceConfigPayload, |
| 36 | type McpStartOAuthInput, | 40 | type McpStartOAuthInput, |
| @@ -46,6 +50,9 @@ type McpExecutorExtension = { · 9 unmodified lines | |||
| 46 | getSourceConfig: ( | 50 | getSourceConfig: ( |
| 47 | sourceId: Source["id"], | 51 | sourceId: Source["id"], |
| 48 | ) => Effect.Effect<McpSourceConfigPayload, Error>; | 52 | ) => Effect.Effect<McpSourceConfigPayload, Error>; |
| 53 | discoverSource: ( | ||
| 54 | input: McpDiscoverInput, | ||
| 55 | ) => Effect.Effect<McpDiscoverResult, Error>; | ||
| 49 | updateSource: ( | 56 | updateSource: ( |
| 50 | input: McpUpdateSourceInput, | 57 | input: McpUpdateSourceInput, |
| 51 | ) => Effect.Effect<Source, Error>; | 58 | ) => Effect.Effect<Source, Error>; |
| @@ -86,6 +93,14 @@ export const McpHttpGroup = HttpApiGroup.make("mcp") · 34 unmodified lines | |||
| 86 | .addError(ControlPlaneForbiddenError) | 93 | .addError(ControlPlaneForbiddenError) |
| 87 | .addError(ControlPlaneStorageError), | 94 | .addError(ControlPlaneStorageError), |
| 88 | ) | 95 | ) |
| 96 | .add( | ||
| 97 | HttpApiEndpoint.post("discoverSource")`/workspaces/${workspaceIdParam}/plugins/mcp/discover` | ||
| 98 | .setPayload(McpDiscoverInputSchema) | ||
| 99 | .addSuccess(McpDiscoverResultSchema) | ||
| 100 | .addError(ControlPlaneBadRequestError) | ||
| 101 | .addError(ControlPlaneForbiddenError) | ||
| 102 | .addError(ControlPlaneStorageError), | ||
| 103 | ) | ||
| 89 | .add( | 104 | .add( |
| 90 | HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/mcp/sources/${sourceIdParam}` | 105 | HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/mcp/sources/${sourceIdParam}` |
| 91 | .addSuccess(McpSourceConfigPayloadSchema) | 106 | .addSuccess(McpSourceConfigPayloadSchema) |
| @@ -171,10 +186,11 @@ const popupDocument = (payload: McpOAuthPopupResult): string => { · 79 unmodified lines | |||
| 171 | .replaceAll("<", "\\u003c") | 186 | .replaceAll("<", "\\u003c") |
| 172 | .replaceAll(">", "\\u003e") | 187 | .replaceAll(">", "\\u003e") |
| 173 | .replaceAll("&", "\\u0026"); | 188 | .replaceAll("&", "\\u0026"); |
| 174 | const title = payload.ok ? "MCP OAuth connected" : "MCP OAuth failed"; | 189 | const title = payload.ok ? "Connected" : "Connection failed"; |
| 175 | const message = payload.ok | 190 | const message = payload.ok |
| 176 | ? "MCP credentials are ready. Return to the source form to finish saving." | 191 | ? "Authentication complete. This window will close automatically." |
| 177 | : payload.error; | 192 | : payload.error; |
| 193 | const statusColor = payload.ok ? "#22c55e" : "#ef4444"; | ||
| 178 | 194 | ||
| 179 | return `<!doctype html> | 195 | return `<!doctype html> |
| 180 | <html lang="en"> | 196 | <html lang="en"> |
| @@ -183,10 +199,17 @@ const popupDocument = (payload: McpOAuthPopupResult): string => { · 2 unmodified lines | |||
| 183 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | 199 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 184 | <title>${escapeHtml(title)}</title> | 200 | <title>${escapeHtml(title)}</title> |
| 185 | </head> | 201 | </head> |
| 186 | <body> | 202 | <body style="font-family: system-ui, -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #fafafa;"> |
| 187 | <main> | 203 | <main style="text-align: center; max-width: 360px; padding: 24px;"> |
| 188 | <h1>${escapeHtml(title)}</h1> | 204 | <div style="width: 40px; height: 40px; border-radius: 50%; background: ${statusColor}; margin: 0 auto 16px; display: flex; align-items: center; justify-content: center;"> |
| 189 | <p>${escapeHtml(message)}</p> | 205 | <svg width="20" height="20" viewBox="0 0 20 20" fill="none"> |
| 206 | ${payload.ok | ||
| 207 | ? '<path d="M6 10l3 3 5-6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' | ||
| 208 | : '<path d="M7 7l6 6M13 7l-6 6" stroke="white" stroke-width="2" stroke-linecap="round"/>'} | ||
| 209 | </svg> | ||
| 210 | </div> | ||
| 211 | <h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 600; color: #111;">${escapeHtml(title)}</h1> | ||
| 212 | <p style="margin: 0; font-size: 14px; color: #666; line-height: 1.5;">${escapeHtml(message)}</p> | ||
| 190 | </main> | 213 | </main> |
| 191 | <script> | 214 | <script> |
| 192 | (() => { | 215 | (() => { |
| @@ -225,6 +248,15 @@ export const mcpHttpPlugin = (): ExecutorHttpPlugin< · 32 unmodified lines | |||
| 225 | Effect.mapError((cause) => toStorageError("mcp.createSource", cause)), | 248 | Effect.mapError((cause) => toStorageError("mcp.createSource", cause)), |
| 226 | ) | 249 | ) |
| 227 | ) | 250 | ) |
| 251 | .handle("discoverSource", ({ path, payload }) => | ||
| 252 | resolveRequestedLocalWorkspace( | ||
| 253 | "mcp.discoverSource", | ||
| 254 | path.workspaceId, | ||
| 255 | ).pipe( | ||
| 256 | Effect.flatMap(() => executor.mcp.discoverSource(payload)), | ||
| 257 | Effect.mapError((cause) => toStorageError("mcp.discoverSource", cause)), | ||
| 258 | ) | ||
| 259 | ) | ||
| 228 | .handle("getSourceConfig", ({ path }) => | 260 | .handle("getSourceConfig", ({ path }) => |
| 229 | resolveRequestedLocalWorkspace( | 261 | resolveRequestedLocalWorkspace( |
| 230 | "mcp.getSourceConfig", | 262 | "mcp.getSourceConfig", |
| @@ -9,7 +9,10 @@ import { · 8 unmodified lines | |||
| 9 | useLocalInstallation, | 9 | useLocalInstallation, |
| 10 | } from "@executor/react"; | 10 | } from "@executor/react"; |
| 11 | import { | 11 | import { |
| 12 | IconCheck, | ||
| 12 | IconPencil, | 13 | IconPencil, |
| 14 | IconSearch, | ||
| 15 | IconSpinner, | ||
| 13 | SourceToolExplorer, | 16 | SourceToolExplorer, |
| 14 | defineExecutorFrontendPlugin, | 17 | defineExecutorFrontendPlugin, |
| 15 | defineFrontendSourceType, | 18 | defineFrontendSourceType, |
| @@ -24,6 +27,8 @@ import { · 8 unmodified lines | |||
| 24 | } from "@executor/plugin-mcp-http"; | 27 | } from "@executor/plugin-mcp-http"; |
| 25 | import { | 28 | import { |
| 26 | type McpConnectInput, | 29 | type McpConnectInput, |
| 30 | type McpDiscoverInput, | ||
| 31 | type McpDiscoverResult, | ||
| 27 | type McpConnectionAuth, | 32 | type McpConnectionAuth, |
| 28 | type McpOAuthPopupResult, | 33 | type McpOAuthPopupResult, |
| 29 | type McpStartOAuthInput, | 34 | type McpStartOAuthInput, |
| @@ -64,6 +69,150 @@ const defaultMcpInput = (): McpConnectInput => ({ · 34 unmodified lines | |||
| 64 | }, | 69 | }, |
| 65 | }); | 70 | }); |
| 66 | 71 | ||
| 72 | type McpQuickPreset = { | ||
| 73 | id: string; | ||
| 74 | name: string; | ||
| 75 | summary: string; | ||
| 76 | input: McpConnectInput; | ||
| 77 | }; | ||
| 78 | |||
| 79 | type ProbeAuthState = { | ||
| 80 | kind: NonNullable<McpDiscoverInput["probeAuth"]>["kind"]; | ||
| 81 | token: string; | ||
| 82 | headerName: string; | ||
| 83 | prefix: string; | ||
| 84 | username: string; | ||
| 85 | password: string; | ||
| 86 | headersText: string; | ||
| 87 | }; | ||
| 88 | |||
| 89 | const defaultProbeAuthState = (): ProbeAuthState => ({ | ||
| 90 | kind: "none", | ||
| 91 | token: "", | ||
| 92 | headerName: "Authorization", | ||
| 93 | prefix: "Bearer ", | ||
| 94 | username: "", | ||
| 95 | password: "", | ||
| 96 | headersText: "", | ||
| 97 | }); | ||
| 98 | |||
| 99 | const mcpQuickPresets: ReadonlyArray<McpQuickPreset> = [ | ||
| 100 | { | ||
| 101 | id: "deepwiki-mcp", | ||
| 102 | name: "DeepWiki MCP", | ||
| 103 | summary: "Repository docs and knowledge graphs via a remote MCP endpoint.", | ||
| 104 | input: { | ||
| 105 | ...defaultMcpInput(), | ||
| 106 | name: "DeepWiki MCP", | ||
| 107 | endpoint: "https://mcp.deepwiki.com/mcp", | ||
| 108 | transport: "auto", | ||
| 109 | }, | ||
| 110 | }, | ||
| 111 | { | ||
| 112 | id: "axiom-mcp", | ||
| 113 | name: "Axiom MCP", | ||
| 114 | summary: "Query and analyze logs and traces through Axiom's MCP server.", | ||
| 115 | input: { | ||
| 116 | ...defaultMcpInput(), | ||
| 117 | name: "Axiom MCP", | ||
| 118 | endpoint: "https://mcp.axiom.co/mcp", | ||
| 119 | transport: "auto", | ||
| 120 | }, | ||
| 121 | }, | ||
| 122 | { | ||
| 123 | id: "neon-mcp", | ||
| 124 | name: "Neon MCP", | ||
| 125 | summary: "Manage databases, branches, and queries via Neon MCP.", | ||
| 126 | input: { | ||
| 127 | ...defaultMcpInput(), | ||
| 128 | name: "Neon MCP", | ||
| 129 | endpoint: "https://mcp.neon.tech/mcp", | ||
| 130 | transport: "auto", | ||
| 131 | }, | ||
| 132 | }, | ||
| 133 | { | ||
| 134 | id: "chrome-devtools-mcp", | ||
| 135 | name: "Chrome DevTools MCP", | ||
| 136 | summary: "Launch the local Chrome DevTools MCP server over stdio.", | ||
| 137 | input: { | ||
| 138 | ...defaultMcpInput(), | ||
| 139 | name: "Chrome DevTools MCP", | ||
| 140 | endpoint: null, | ||
| 141 | transport: "stdio", | ||
| 142 | command: "npx", | ||
| 143 | args: ["-y", "chrome-devtools-mcp@latest"], | ||
| 144 | }, | ||
| 145 | }, | ||
| 146 | ]; | ||
| 147 | |||
| 148 | const presetString = ( | ||
| 149 | search: Record<string, unknown>, | ||
| 150 | key: string, | ||
| 151 | ): string | null => { | ||
| 152 | const value = search[key]; | ||
| 153 | return typeof value === "string" && value.trim().length > 0 | ||
| 154 | ? value.trim() | ||
| 155 | : null; | ||
| 156 | }; | ||
| 157 | |||
| 158 | const presetTransport = ( | ||
| 159 | search: Record<string, unknown>, | ||
| 160 | ): McpConnectInput["transport"] => { | ||
| 161 | const value = presetString(search, "presetTransport"); | ||
| 162 | return value === "auto" || | ||
| 163 | value === "streamable-http" || | ||
| 164 | value === "sse" || | ||
| 165 | value === "stdio" | ||
| 166 | ? value | ||
| 167 | : null; | ||
| 168 | }; | ||
| 169 | |||
| 170 | const presetJsonStringMap = ( | ||
| 171 | search: Record<string, unknown>, | ||
| 172 | key: string, | ||
| 173 | ): Record<string, string> | null => { | ||
| 174 | const value = presetString(search, key); | ||
| 175 | return value ? parseJsonStringMap(key, value) : null; | ||
| 176 | }; | ||
| 177 | |||
| 178 | const presetJsonStringArray = ( | ||
| 179 | search: Record<string, unknown>, | ||
| 180 | key: string, | ||
| 181 | ): Array<string> | null => { | ||
| 182 | const value = presetString(search, key); | ||
| 183 | return value ? parseJsonStringArray(key, value) : null; | ||
| 184 | }; | ||
| 185 | |||
| 186 | const mcpInputFromSearch = ( | ||
| 187 | search: Record<string, unknown>, | ||
| 188 | ): McpConnectInput => { | ||
| 189 | const defaults = defaultMcpInput(); | ||
| 190 | const command = presetString(search, "presetCommand"); | ||
| 191 | const args = presetJsonStringArray(search, "presetArgs"); | ||
| 192 | const env = presetJsonStringMap(search, "presetEnv"); | ||
| 193 | const cwd = presetString(search, "presetCwd"); | ||
| 194 | const queryParams = presetJsonStringMap(search, "presetQueryParams"); | ||
| 195 | const headers = presetJsonStringMap(search, "presetHeaders"); | ||
| 196 | const transport = presetTransport(search) | ||
| 197 | ?? (command ? "stdio" : defaults.transport); | ||
| 198 | |||
| 199 | return { | ||
| 200 | ...defaults, | ||
| 201 | name: presetString(search, "presetName") ?? defaults.name, | ||
| 202 | endpoint: | ||
| 203 | command | ||
| 204 | ? null | ||
| 205 | : presetString(search, "presetEndpoint") ?? defaults.endpoint, | ||
| 206 | transport, | ||
| 207 | queryParams, | ||
| 208 | headers, | ||
| 209 | command, | ||
| 210 | args, | ||
| 211 | env, | ||
| 212 | cwd, | ||
| 213 | }; | ||
| 214 | }; | ||
| 215 | |||
| 67 | const stringifyStringMap = ( | 216 | const stringifyStringMap = ( |
| 68 | value: Record<string, string> | null | undefined, | 217 | value: Record<string, string> | null | undefined, |
| 69 | ): string => | 218 | ): string => |
| @@ -92,6 +241,49 @@ const transportFieldsFromInput = (input: McpConnectInput): McpTransportFields => · 22 unmodified lines | |||
| 92 | headersText: stringifyStringMap(input.headers), | 241 | headersText: stringifyStringMap(input.headers), |
| 93 | }; | 242 | }; |
| 94 | 243 | ||
| 244 | const buildProbeAuth = ( | ||
| 245 | state: ProbeAuthState, | ||
| 246 | ): McpDiscoverInput["probeAuth"] => { | ||
| 247 | if (state.kind === "none") { | ||
| 248 | return { kind: "none" }; | ||
| 249 | } | ||
| 250 | |||
| 251 | if (state.kind === "bearer") { | ||
| 252 | if (!state.token.trim()) { | ||
| 253 | throw new Error("Token is required for bearer discovery auth."); | ||
| 254 | } | ||
| 255 | |||
| 256 | return { | ||
| 257 | kind: "bearer", | ||
| 258 | headerName: state.headerName.trim() || null, | ||
| 259 | prefix: state.prefix.length > 0 ? state.prefix : null, | ||
| 260 | token: state.token.trim(), | ||
| 261 | }; | ||
| 262 | } | ||
| 263 | |||
| 264 | if (state.kind === "basic") { | ||
| 265 | if (!state.username.trim()) { | ||
| 266 | throw new Error("Username is required for basic discovery auth."); | ||
| 267 | } | ||
| 268 | |||
| 269 | return { | ||
| 270 | kind: "basic", | ||
| 271 | username: state.username.trim(), | ||
| 272 | password: state.password, | ||
| 273 | }; | ||
| 274 | } | ||
| 275 | |||
| 276 | const headers = parseJsonStringMap("Discovery headers", state.headersText); | ||
| 277 | if (!headers) { | ||
| 278 | throw new Error("At least one discovery header is required."); | ||
| 279 | } | ||
| 280 | |||
| 281 | return { | ||
| 282 | kind: "headers", | ||
| 283 | headers, | ||
| 284 | }; | ||
| 285 | }; | ||
| 286 | |||
| 95 | const waitForOauthPopupResult = async ( | 287 | const waitForOauthPopupResult = async ( |
| 96 | sessionId: string, | 288 | sessionId: string, |
| 97 | ): Promise<McpOAuthPopupResult> => | 289 | ): Promise<McpOAuthPopupResult> => |
| @@ -150,6 +342,28 @@ const waitForOauthPopupResult = async ( · 52 unmodified lines | |||
| 150 | }, 400); | 342 | }, 400); |
| 151 | }); | 343 | }); |
| 152 | 344 | ||
| 345 | const openOauthPopup = (): Window | null => { | ||
| 346 | const popup = window.open("", "executor-mcp-oauth", "width=560,height=760"); | ||
| 347 | if (!popup) { | ||
| 348 | return null; | ||
| 349 | } | ||
| 350 | |||
| 351 | try { | ||
| 352 | popup.document.title = "Connecting"; | ||
| 353 | popup.document.body.innerHTML = ` | ||
| 354 | <main style="font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; color: #333;"> | ||
| 355 | <div style="width: 20px; height: 20px; border: 2px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; margin-bottom: 16px;"></div> | ||
| 356 | <p style="margin: 0; font-size: 14px; color: #888;">Redirecting to sign in…</p> | ||
| 357 | <style>@keyframes spin { to { transform: rotate(360deg); } }</style> | ||
| 358 | </main> | ||
| 359 | `; | ||
| 360 | } catch { | ||
| 361 | // Ignore cases where the browser does not allow touching the popup document. | ||
| 362 | } | ||
| 363 | |||
| 364 | return popup; | ||
| 365 | }; | ||
| 366 | |||
| 153 | function McpSourceForm(props: { | 367 | function McpSourceForm(props: { |
| 154 | initialValue: McpConnectInput; | 368 | initialValue: McpConnectInput; |
| 155 | mode: "create" | "edit"; | 369 | mode: "create" | "edit"; |
| @@ -157,6 +371,10 @@ function McpSourceForm(props: { · 1 unmodified line | |||
| 157 | }) { | 371 | }) { |
| 158 | const installation = useLocalInstallation(); | 372 | const installation = useLocalInstallation(); |
| 159 | const client = getMcpHttpClient(); | 373 | const client = getMcpHttpClient(); |
| 374 | const discoverSource = useAtomSet( | ||
| 375 | client.mutation("mcp", "discoverSource"), | ||
| 376 | { mode: "promise" }, | ||
| 377 | ); | ||
| 160 | const startOAuth = useAtomSet( | 378 | const startOAuth = useAtomSet( |
| 161 | client.mutation("mcp", "startOAuth"), | 379 | client.mutation("mcp", "startOAuth"), |
| 162 | { mode: "promise" }, | 380 | { mode: "promise" }, |
| @@ -181,9 +399,104 @@ function McpSourceForm(props: { · 18 unmodified lines | |||
| 181 | props.initialValue.auth.kind === "oauth2" ? "connected" : "idle", | 399 | props.initialValue.auth.kind === "oauth2" ? "connected" : "idle", |
| 182 | ); | 400 | ); |
| 183 | const [error, setError] = useState<string | null>(null); | 401 | const [error, setError] = useState<string | null>(null); |
| 402 | const [discoveryEndpoint, setDiscoveryEndpoint] = useState( | ||
| 403 | props.initialValue.command ? "" : (props.initialValue.endpoint ?? ""), | ||
| 404 | ); | ||
| 405 | const [showProbeAuth, setShowProbeAuth] = useState(false); | ||
| 406 | const [probeAuth, setProbeAuth] = useState<ProbeAuthState>(defaultProbeAuthState); | ||
| 407 | const [discoveryMessage, setDiscoveryMessage] = useState<string | null>(null); | ||
| 408 | const discoverMutation = useExecutorMutation<McpDiscoverInput, McpDiscoverResult>( | ||
| 409 | async (input) => { | ||
| 410 | if (installation.status !== "ready") { | ||
| 411 | throw new Error("Workspace is still loading."); | ||
| 412 | } | ||
| 413 | |||
| 414 | return discoverSource({ | ||
| 415 | path: { | ||
| 416 | workspaceId: installation.data.scopeId, | ||
| 417 | }, | ||
| 418 | payload: input, | ||
| 419 | }); | ||
| 420 | }, | ||
| 421 | ); | ||
| 184 | 422 | ||
| 185 | const isStdio = transportFields.transport === "stdio"; | 423 | const isStdio = transportFields.transport === "stdio"; |
| 186 | 424 | ||
| 425 | const applyPreset = (preset: McpQuickPreset) => { | ||
| 426 | setName(preset.input.name); | ||
| 427 | setEndpoint(preset.input.endpoint ?? ""); | ||
| 428 | setTransportFields(transportFieldsFromInput(preset.input)); | ||
| 429 | setAuthKind(preset.input.auth.kind); | ||
| 430 | setOauthAuth(preset.input.auth.kind === "oauth2" ? preset.input.auth : null); | ||
| 431 | setOauthStatus(preset.input.auth.kind === "oauth2" ? "connected" : "idle"); | ||
| 432 | setDiscoveryEndpoint(preset.input.endpoint ?? ""); | ||
| 433 | setDiscoveryMessage(`Loaded ${preset.name}.`); | ||
| 434 | setError(null); | ||
| 435 | }; | ||
| 436 | |||
| 437 | const applyDiscoveredRemoteSource = (result: NonNullable<McpDiscoverResult>) => { | ||
| 438 | const remoteTransport = | ||
| 439 | result.transport === "streamable-http" || | ||
| 440 | result.transport === "sse" || | ||
| 441 | result.transport === "auto" | ||
| 442 | ? result.transport | ||
| 443 | : "auto"; | ||
| 444 | |||
| 445 | setName(result.name?.trim() || name); | ||
| 446 | setEndpoint(result.endpoint); | ||
| 447 | setTransportFields({ | ||
| 448 | ...defaultMcpRemoteTransportFields(remoteTransport), | ||
| 449 | queryParamsText: | ||
| 450 | transportFields.transport === "stdio" ? "" : transportFields.queryParamsText, | ||
| 451 | headersText: | ||
| 452 | transportFields.transport === "stdio" ? "" : transportFields.headersText, | ||
| 453 | }); | ||
| 454 | if (result.authInference.supported && result.authInference.suggestedKind === "oauth2") { | ||
| 455 | setAuthKind("oauth2"); | ||
| 456 | setDiscoveryMessage( | ||
| 457 | result.warnings[0] | ||
| 458 | ?? "The server advertised OAuth during discovery. Connect OAuth before saving.", | ||
| 459 | ); | ||
| 460 | } else { | ||
| 461 | setAuthKind("none"); | ||
| 462 | setOauthAuth(null); | ||
| 463 | setOauthStatus("idle"); | ||
| 464 | setDiscoveryMessage( | ||
| 465 | result.warnings[0] | ||
| 466 | ?? `Discovered ${result.toolCount ?? "unknown"} MCP tools and prefilled the connection.`, | ||
| 467 | ); | ||
| 468 | } | ||
| 469 | setError(null); | ||
| 470 | }; | ||
| 471 | |||
| 472 | const handleDiscover = async () => { | ||
| 473 | if (installation.status !== "ready") { | ||
| 474 | setError("Workspace is still loading."); | ||
| 475 | return; | ||
| 476 | } | ||
| 477 | |||
| 478 | setError(null); | ||
| 479 | setDiscoveryMessage(null); | ||
| 480 | |||
| 481 | try { | ||
| 482 | const result = await discoverMutation.mutateAsync({ | ||
| 483 | endpoint: discoveryEndpoint.trim(), | ||
| 484 | probeAuth: showProbeAuth ? buildProbeAuth(probeAuth) : { kind: "none" }, | ||
| 485 | }); | ||
| 486 | |||
| 487 | if (result === null) { | ||
| 488 | setDiscoveryMessage( | ||
| 489 | "Could not verify this endpoint as MCP. You can still continue with manual setup.", | ||
| 490 | ); | ||
| 491 | return; | ||
| 492 | } | ||
| 493 | |||
| 494 | applyDiscoveredRemoteSource(result); | ||
| 495 | } catch (cause) { | ||
| 496 | setError(cause instanceof Error ? cause.message : String(cause)); | ||
| 497 | } | ||
| 498 | }; | ||
| 499 | |||
| 187 | const runOauth = async () => { | 500 | const runOauth = async () => { |
| 188 | if (installation.status !== "ready") { | 501 | if (installation.status !== "ready") { |
| 189 | throw new Error("Workspace is still loading."); | 502 | throw new Error("Workspace is still loading."); |
| @@ -192,6 +505,11 @@ function McpSourceForm(props: { · 2 unmodified lines | |||
| 192 | throw new Error("MCP OAuth is only available for remote MCP transports."); | 505 | throw new Error("MCP OAuth is only available for remote MCP transports."); |
| 193 | } | 506 | } |
| 194 | 507 | ||
| 508 | const popup = openOauthPopup(); | ||
| 509 | if (!popup) { | ||
| 510 | throw new Error("Failed opening MCP OAuth popup. Allow popups for this site and try again."); | ||
| 511 | } | ||
| 512 | |||
| 195 | const payload: McpStartOAuthInput = { | 513 | const payload: McpStartOAuthInput = { |
| 196 | endpoint: endpoint.trim(), | 514 | endpoint: endpoint.trim(), |
| 197 | queryParams: parseJsonStringMap("Query params", transportFields.queryParamsText), | 515 | queryParams: parseJsonStringMap("Query params", transportFields.queryParamsText), |
| @@ -201,22 +519,22 @@ function McpSourceForm(props: { · 3 unmodified lines | |||
| 201 | ).toString(), | 519 | ).toString(), |
| 202 | }; | 520 | }; |
| 203 | 521 | ||
| 204 | const started = await startOAuth({ | 522 | let started; |
| 205 | path: { | 523 | try { |
| 206 | workspaceId: installation.data.scopeId, | 524 | started = await startOAuth({ |
| 207 | }, | 525 | path: { |
| 208 | payload, | 526 | workspaceId: installation.data.scopeId, |
| 209 | }); | 527 | }, |
| 210 | 528 | payload, | |
| 211 | const popup = window.open( | 529 | }); |
| 212 | started.authorizationUrl, | 530 | } catch (cause) { |
| 213 | "executor-mcp-oauth", | 531 | popup.close(); |
| 214 | "width=560,height=760,noopener,noreferrer", | 532 | throw cause; |
| 215 | ); | ||
| 216 | if (!popup) { | ||
| 217 | throw new Error("Failed opening MCP OAuth popup."); | ||
| 218 | } | 533 | } |
| 219 | 534 | ||
| 535 | popup.location.replace(started.authorizationUrl); | ||
| 536 | popup.focus(); | ||
| 537 | |||
| 220 | const result = await waitForOauthPopupResult(started.sessionId); | 538 | const result = await waitForOauthPopupResult(started.sessionId); |
| 221 | if (!result.ok) { | 539 | if (!result.ok) { |
| 222 | throw new Error(result.error); | 540 | throw new Error(result.error); |
| @@ -256,26 +574,189 @@ function McpSourceForm(props: { · 33 unmodified lines | |||
| 256 | }); | 574 | }); |
| 257 | 575 | ||
| 258 | return ( | 576 | return ( |
| 259 | <div className="space-y-4 rounded-2xl border border-border bg-card p-5"> | 577 | <div className="space-y-8"> |
| 260 | <div> | 578 | {props.mode === "create" && ( |
| 261 | <h2 className="text-lg font-semibold text-foreground"> | 579 | <div className="rounded-xl border border-border p-6"> |
| 262 | {props.mode === "create" ? "Connect MCP Source" : "Edit MCP Source"} | 580 | <div className="text-sm font-medium text-foreground">Presets</div> |
| 263 | </h2> | 581 | <div className="mt-3 grid gap-3 sm:grid-cols-2"> |
| 264 | <p className="mt-1 text-sm text-muted-foreground"> | 582 | {mcpQuickPresets.map((preset) => { |
| 265 | MCP now owns its transport config, OAuth flow, and tool surface inside the plugin. | 583 | const selected = |
| 266 | </p> | 584 | preset.input.transport === "stdio" |
| 267 | </div> | 585 | ? transportFields.transport === "stdio" |
| 586 | && transportFields.command === (preset.input.command ?? "") | ||
| 587 | : !isStdio && endpoint.trim() === (preset.input.endpoint ?? ""); | ||
| 588 | |||
| 589 | return ( | ||
| 590 | <button | ||
| 591 | key={preset.id} | ||
| 592 | type="button" | ||
| 593 | onClick={() => applyPreset(preset)} | ||
| 594 | className={ | ||
| 595 | selected | ||
| 596 | ? "rounded-lg border border-primary/30 bg-primary/5 px-4 py-3 text-left" | ||
| 597 | : "rounded-lg border border-border px-4 py-3 text-left transition-colors hover:bg-accent/40" | ||
| 598 | } | ||
| 599 | > | ||
| 600 | <div className="flex items-center gap-2"> | ||
| 601 | {selected ? <IconCheck className="size-3.5 text-primary" /> : null} | ||
| 602 | <div className="text-sm font-medium text-foreground">{preset.name}</div> | ||
| 603 | </div> | ||
| 604 | <div className="mt-1 text-xs text-muted-foreground">{preset.summary}</div> | ||
| 605 | </button> | ||
| 606 | ); | ||
| 607 | })} | ||
| 608 | </div> | ||
| 609 | </div> | ||
| 610 | )} | ||
| 611 | |||
| 612 | {props.mode === "create" && ( | ||
| 613 | <div className="rounded-xl border border-border p-6"> | ||
| 614 | <div> | ||
| 615 | <div className="flex items-center justify-between gap-3"> | ||
| 616 | <div className="text-sm font-medium text-foreground">Discover</div> | ||
| 617 | <button | ||
| 618 | type="button" | ||
| 619 | onClick={() => setShowProbeAuth((current) => !current)} | ||
| 620 | className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" | ||
| 621 | > | ||
| 622 | {showProbeAuth ? "Hide auth" : "Need auth?"} | ||
| 623 | </button> | ||
| 624 | </div> | ||
| 625 | |||
| 626 | <div className="mt-2 flex flex-col gap-3 sm:flex-row"> | ||
| 627 | <input | ||
| 628 | value={discoveryEndpoint} | ||
| 629 | onChange={(event) => setDiscoveryEndpoint(event.target.value)} | ||
| 630 | placeholder="https://mcp.example.com/mcp" | ||
| 631 | className="h-9 flex-1 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 632 | /> | ||
| 633 | <button | ||
| 634 | type="button" | ||
| 635 | onClick={() => { | ||
| 636 | void handleDiscover(); | ||
| 637 | }} | ||
| 638 | disabled={discoverMutation.status === "pending"} | ||
| 639 | className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50" | ||
| 640 | > | ||
| 641 | {discoverMutation.status === "pending" | ||
| 642 | ? <IconSpinner className="size-3.5" /> | ||
| 643 | : <IconSearch className="size-3.5" />} | ||
| 644 | {discoverMutation.status === "pending" ? "Discovering..." : "Discover"} | ||
| 645 | </button> | ||
| 646 | </div> | ||
| 647 | |||
| 648 | {showProbeAuth && ( | ||
| 649 | <div className="mt-3 space-y-3 border-l-2 border-border pl-4"> | ||
| 650 | <label className="grid gap-2"> | ||
| 651 | <span className="text-xs font-medium text-foreground">Discovery auth</span> | ||
| 652 | <select | ||
| 653 | value={probeAuth.kind} | ||
| 654 | onChange={(event) => | ||
| 655 | setProbeAuth((current) => ({ | ||
| 656 | ...current, | ||
| 657 | kind: event.target.value as ProbeAuthState["kind"], | ||
| 658 | }))} | ||
| 659 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 660 | > | ||
| 661 | <option value="none">None</option> | ||
| 662 | <option value="bearer">Bearer</option> | ||
| 663 | <option value="basic">Basic</option> | ||
| 664 | <option value="headers">Custom headers</option> | ||
| 665 | </select> | ||
| 666 | </label> | ||
| 268 | 667 | ||
| 269 | <label className="grid gap-1.5"> | 668 | {probeAuth.kind === "bearer" && ( |
| 669 | <div className="grid gap-4 md:grid-cols-2"> | ||
| 670 | <label className="grid gap-2"> | ||
| 671 | <span className="text-xs font-medium text-foreground">Header name</span> | ||
| 672 | <input | ||
| 673 | value={probeAuth.headerName} | ||
| 674 | onChange={(event) => | ||
| 675 | setProbeAuth((current) => ({ ...current, headerName: event.target.value }))} | ||
| 676 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 677 | /> | ||
| 678 | </label> | ||
| 679 | <label className="grid gap-2"> | ||
| 680 | <span className="text-xs font-medium text-foreground">Prefix</span> | ||
| 681 | <input | ||
| 682 | value={probeAuth.prefix} | ||
| 683 | onChange={(event) => | ||
| 684 | setProbeAuth((current) => ({ ...current, prefix: event.target.value }))} | ||
| 685 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 686 | /> | ||
| 687 | </label> | ||
| 688 | <label className="grid gap-2 md:col-span-2"> | ||
| 689 | <span className="text-xs font-medium text-foreground">Token</span> | ||
| 690 | <input | ||
| 691 | value={probeAuth.token} | ||
| 692 | onChange={(event) => | ||
| 693 | setProbeAuth((current) => ({ ...current, token: event.target.value }))} | ||
| 694 | className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 695 | /> | ||
| 696 | </label> | ||
| 697 | </div> | ||
| 698 | )} | ||
| 699 | |||
| 700 | {probeAuth.kind === "basic" && ( | ||
| 701 | <div className="grid gap-4 md:grid-cols-2"> | ||
| 702 | <label className="grid gap-2"> | ||
| 703 | <span className="text-xs font-medium text-foreground">Username</span> | ||
| 704 | <input | ||
| 705 | value={probeAuth.username} | ||
| 706 | onChange={(event) => | ||
| 707 | setProbeAuth((current) => ({ ...current, username: event.target.value }))} | ||
| 708 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 709 | /> | ||
| 710 | </label> | ||
| 711 | <label className="grid gap-2"> | ||
| 712 | <span className="text-xs font-medium text-foreground">Password</span> | ||
| 713 | <input | ||
| 714 | value={probeAuth.password} | ||
| 715 | onChange={(event) => | ||
| 716 | setProbeAuth((current) => ({ ...current, password: event.target.value }))} | ||
| 717 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 718 | type="password" | ||
| 719 | /> | ||
| 720 | </label> | ||
| 721 | </div> | ||
| 722 | )} | ||
| 723 | |||
| 724 | {probeAuth.kind === "headers" && ( | ||
| 725 | <label className="grid gap-2"> | ||
| 726 | <span className="text-xs font-medium text-foreground">Headers JSON</span> | ||
| 727 | <textarea | ||
| 728 | value={probeAuth.headersText} | ||
| 729 | onChange={(event) => | ||
| 730 | setProbeAuth((current) => ({ ...current, headersText: event.target.value }))} | ||
| 731 | rows={3} | ||
| 732 | placeholder='{"x-api-key":"..."}' | ||
| 733 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 734 | /> | ||
| 735 | </label> | ||
| 736 | )} | ||
| 737 | </div> | ||
| 738 | )} | ||
| 739 | |||
| 740 | {discoveryMessage && ( | ||
| 741 | <div className="mt-2 text-xs text-muted-foreground"> | ||
| 742 | {discoveryMessage} | ||
| 743 | </div> | ||
| 744 | )} | ||
| 745 | </div> | ||
| 746 | </div> | ||
| 747 | )} | ||
| 748 | |||
| 749 | <div className="space-y-6 rounded-xl border border-border p-6"> | ||
| 750 | <label className="grid gap-2"> | ||
| 270 | <span className="text-xs font-medium text-foreground">Name</span> | 751 | <span className="text-xs font-medium text-foreground">Name</span> |
| 271 | <input | 752 | <input |
| 272 | value={name} | 753 | value={name} |
| 273 | onChange={(event) => setName(event.target.value)} | 754 | onChange={(event) => setName(event.target.value)} |
| 274 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 755 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 275 | /> | 756 | /> |
| 276 | </label> | 757 | </label> |
| 277 | 758 | ||
| 278 | <label className="grid gap-1.5"> | 759 | <label className="grid gap-2"> |
| 279 | <span className="text-xs font-medium text-foreground">Transport</span> | 760 | <span className="text-xs font-medium text-foreground">Transport</span> |
| 280 | <select | 761 | <select |
| 281 | value={transportFields.transport} | 762 | value={transportFields.transport} |
| @@ -290,7 +771,7 @@ function McpSourceForm(props: { · 8 unmodified lines | |||
| 290 | setOauthStatus("idle"); | 771 | setOauthStatus("idle"); |
| 291 | } | 772 | } |
| 292 | }} | 773 | }} |
| 293 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 774 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 294 | > | 775 | > |
| 295 | <option value="">Auto (remote)</option> | 776 | <option value="">Auto (remote)</option> |
| 296 | <option value="auto">Auto</option> | 777 | <option value="auto">Auto</option> |
| @@ -302,7 +783,7 @@ function McpSourceForm(props: { · 5 unmodified lines | |||
| 302 | 783 | ||
| 303 | {isStdio ? ( | 784 | {isStdio ? ( |
| 304 | <div className="grid gap-4 md:grid-cols-2"> | 785 | <div className="grid gap-4 md:grid-cols-2"> |
| 305 | <label className="grid gap-1.5 md:col-span-2"> | 786 | <label className="grid gap-2 md:col-span-2"> |
| 306 | <span className="text-xs font-medium text-foreground">Command</span> | 787 | <span className="text-xs font-medium text-foreground">Command</span> |
| 307 | <input | 788 | <input |
| 308 | value={transportFields.command} | 789 | value={transportFields.command} |
| @@ -311,11 +792,11 @@ function McpSourceForm(props: { · 2 unmodified lines | |||
| 311 | ...transportFields, | 792 | ...transportFields, |
| 312 | command: event.target.value, | 793 | command: event.target.value, |
| 313 | })} | 794 | })} |
| 314 | className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 795 | className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 315 | /> | 796 | /> |
| 316 | </label> | 797 | </label> |
| 317 | 798 | ||
| 318 | <label className="grid gap-1.5"> | 799 | <label className="grid gap-2"> |
| 319 | <span className="text-xs font-medium text-foreground">Args</span> | 800 | <span className="text-xs font-medium text-foreground">Args</span> |
| 320 | <textarea | 801 | <textarea |
| 321 | value={transportFields.argsText} | 802 | value={transportFields.argsText} |
| @@ -324,13 +805,13 @@ function McpSourceForm(props: { · 2 unmodified lines | |||
| 324 | ...transportFields, | 805 | ...transportFields, |
| 325 | argsText: event.target.value, | 806 | argsText: event.target.value, |
| 326 | })} | 807 | })} |
| 327 | rows={4} | 808 | rows={3} |
| 328 | placeholder='["server.js","--port","8787"]' | 809 | placeholder='["server.js","--port","8787"]' |
| 329 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 810 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 330 | /> | 811 | /> |
| 331 | </label> | 812 | </label> |
| 332 | 813 | ||
| 333 | <label className="grid gap-1.5"> | 814 | <label className="grid gap-2"> |
| 334 | <span className="text-xs font-medium text-foreground">Environment</span> | 815 | <span className="text-xs font-medium text-foreground">Environment</span> |
| 335 | <textarea | 816 | <textarea |
| 336 | value={transportFields.envText} | 817 | value={transportFields.envText} |
| @@ -339,13 +820,13 @@ function McpSourceForm(props: { · 2 unmodified lines | |||
| 339 | ...transportFields, | 820 | ...transportFields, |
| 340 | envText: event.target.value, | 821 | envText: event.target.value, |
| 341 | })} | 822 | })} |
| 342 | rows={4} | 823 | rows={3} |
| 343 | placeholder='{"NODE_ENV":"production"}' | 824 | placeholder='{"NODE_ENV":"production"}' |
| 344 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 825 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 345 | /> | 826 | /> |
| 346 | </label> | 827 | </label> |
| 347 | 828 | ||
| 348 | <label className="grid gap-1.5 md:col-span-2"> | 829 | <label className="grid gap-2 md:col-span-2"> |
| 349 | <span className="text-xs font-medium text-foreground">Working Directory</span> | 830 | <span className="text-xs font-medium text-foreground">Working Directory</span> |
| 350 | <input | 831 | <input |
| 351 | value={transportFields.cwd} | 832 | value={transportFields.cwd} |
| @@ -354,22 +835,22 @@ function McpSourceForm(props: { · 2 unmodified lines | |||
| 354 | ...transportFields, | 835 | ...transportFields, |
| 355 | cwd: event.target.value, | 836 | cwd: event.target.value, |
| 356 | })} | 837 | })} |
| 357 | className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 838 | className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 358 | /> | 839 | /> |
| 359 | </label> | 840 | </label> |
| 360 | </div> | 841 | </div> |
| 361 | ) : ( | 842 | ) : ( |
| 362 | <div className="grid gap-4 md:grid-cols-2"> | 843 | <div className="grid gap-4 md:grid-cols-2"> |
| 363 | <label className="grid gap-1.5 md:col-span-2"> | 844 | <label className="grid gap-2 md:col-span-2"> |
| 364 | <span className="text-xs font-medium text-foreground">Endpoint</span> | 845 | <span className="text-xs font-medium text-foreground">Endpoint</span> |
| 365 | <input | 846 | <input |
| 366 | value={endpoint} | 847 | value={endpoint} |
| 367 | onChange={(event) => setEndpoint(event.target.value)} | 848 | onChange={(event) => setEndpoint(event.target.value)} |
| 368 | className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 849 | className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 369 | /> | 850 | /> |
| 370 | </label> | 851 | </label> |
| 371 | 852 | ||
| 372 | <label className="grid gap-1.5"> | 853 | <label className="grid gap-2"> |
| 373 | <span className="text-xs font-medium text-foreground">Query Params</span> | 854 | <span className="text-xs font-medium text-foreground">Query Params</span> |
| 374 | <textarea | 855 | <textarea |
| 375 | value={transportFields.queryParamsText} | 856 | value={transportFields.queryParamsText} |
| @@ -378,13 +859,13 @@ function McpSourceForm(props: { · 2 unmodified lines | |||
| 378 | ...transportFields, | 859 | ...transportFields, |
| 379 | queryParamsText: event.target.value, | 860 | queryParamsText: event.target.value, |
| 380 | })} | 861 | })} |
| 381 | rows={4} | 862 | rows={3} |
| 382 | placeholder='{"transport":"streamable-http"}' | 863 | placeholder='{"transport":"streamable-http"}' |
| 383 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 864 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 384 | /> | 865 | /> |
| 385 | </label> | 866 | </label> |
| 386 | 867 | ||
| 387 | <label className="grid gap-1.5"> | 868 | <label className="grid gap-2"> |
| 388 | <span className="text-xs font-medium text-foreground">Headers</span> | 869 | <span className="text-xs font-medium text-foreground">Headers</span> |
| 389 | <textarea | 870 | <textarea |
| 390 | value={transportFields.headersText} | 871 | value={transportFields.headersText} |
| @@ -393,7 +874,7 @@ function McpSourceForm(props: { · 2 unmodified lines | |||
| 393 | ...transportFields, | 874 | ...transportFields, |
| 394 | headersText: event.target.value, | 875 | headersText: event.target.value, |
| 395 | })} | 876 | })} |
| 396 | rows={4} | 877 | rows={3} |
| 397 | placeholder='{"x-api-key":"..."}' | 878 | placeholder='{"x-api-key":"..."}' |
| 398 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 879 | className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 399 | /> | 880 | /> |
| @@ -402,7 +883,7 @@ function McpSourceForm(props: { · 2 unmodified lines | |||
| 402 | )} | 883 | )} |
| 403 | 884 | ||
| 404 | {!isStdio && ( | 885 | {!isStdio && ( |
| 405 | <label className="grid gap-1.5"> | 886 | <label className="grid gap-2"> |
| 406 | <span className="text-xs font-medium text-foreground">Auth</span> | 887 | <span className="text-xs font-medium text-foreground">Auth</span> |
| 407 | <select | 888 | <select |
| 408 | value={authKind} | 889 | value={authKind} |
| @@ -414,7 +895,7 @@ function McpSourceForm(props: { · 5 unmodified lines | |||
| 414 | setOauthStatus("idle"); | 895 | setOauthStatus("idle"); |
| 415 | } | 896 | } |
| 416 | }} | 897 | }} |
| 417 | className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | 898 | className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 418 | > | 899 | > |
| 419 | <option value="none">None</option> | 900 | <option value="none">None</option> |
| 420 | <option value="oauth2">OAuth 2.0</option> | 901 | <option value="oauth2">OAuth 2.0</option> |
| @@ -423,14 +904,12 @@ function McpSourceForm(props: { · 2 unmodified lines | |||
| 423 | )} | 904 | )} |
| 424 | 905 | ||
| 425 | {!isStdio && authKind === "oauth2" && ( | 906 | {!isStdio && authKind === "oauth2" && ( |
| 426 | <div className="rounded-xl border border-border/70 bg-muted/40 p-4"> | 907 | <div className="border-l-2 border-border pl-4"> |
| 427 | <div className="flex flex-wrap items-center justify-between gap-3"> | 908 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 428 | <div> | 909 | <div> |
| 429 | <div className="text-sm font-medium text-foreground"> | 910 | <div className="text-sm font-medium text-foreground">OAuth</div> |
| 430 | MCP OAuth | ||
| 431 | </div> | ||
| 432 | <div className="mt-1 text-xs text-muted-foreground"> | 911 | <div className="mt-1 text-xs text-muted-foreground"> |
| 433 | Authenticate directly against the MCP server and keep the token refs in plugin storage. | 912 | Authenticate with the MCP server's built-in OAuth flow. |
| 434 | </div> | 913 | </div> |
| 435 | </div> | 914 | </div> |
| 436 | <button | 915 | <button |
| @@ -451,9 +930,11 @@ function McpSourceForm(props: { · 14 unmodified lines | |||
| 451 | {oauthStatus === "connected" ? "Reconnect OAuth" : "Connect OAuth"} | 930 | {oauthStatus === "connected" ? "Reconnect OAuth" : "Connect OAuth"} |
| 452 | </button> | 931 | </button> |
| 453 | </div> | 932 | </div> |
| 454 | <div className="mt-3 text-xs text-muted-foreground"> | 933 | {oauthStatus === "connected" && ( |
| 455 | Status: <span className="text-foreground">{oauthStatus}</span> | 934 | <div className="mt-3 text-xs text-primary"> |
| 456 | </div> | 935 | Connected |
| 936 | </div> | ||
| 937 | )} | ||
| 457 | </div> | 938 | </div> |
| 458 | )} | 939 | )} |
| 459 | 940 | ||
| @@ -475,7 +956,7 @@ function McpSourceForm(props: { · 15 unmodified lines | |||
| 475 | ); | 956 | ); |
| 476 | }} | 957 | }} |
| 477 | disabled={submitMutation.status === "pending"} | 958 | disabled={submitMutation.status === "pending"} |
| 478 | className="inline-flex h-10 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-60" | 959 | className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50" |
| 479 | > | 960 | > |
| 480 | {submitMutation.status === "pending" | 961 | {submitMutation.status === "pending" |
| 481 | ? props.mode === "create" | 962 | ? props.mode === "create" |
| @@ -486,12 +967,14 @@ function McpSourceForm(props: { · 4 unmodified lines | |||
| 486 | : "Save Changes"} | 967 | : "Save Changes"} |
| 487 | </button> | 968 | </button> |
| 488 | </div> | 969 | </div> |
| 970 | </div> | ||
| 489 | </div> | 971 | </div> |
| 490 | ); | 972 | ); |
| 491 | } | 973 | } |
| 492 | 974 | ||
| 493 | function McpAddPage() { | 975 | function McpAddPage() { |
| 494 | const navigation = useSourcePluginNavigation(); | 976 | const navigation = useSourcePluginNavigation(); |
| 977 | const initialValue = mcpInputFromSearch(useSourcePluginSearch()); | ||
| 495 | const installation = useLocalInstallation(); | 978 | const installation = useLocalInstallation(); |
| 496 | const client = getMcpHttpClient(); | 979 | const client = getMcpHttpClient(); |
| 497 | const createSource = useAtomSet( | 980 | const createSource = useAtomSet( |
| @@ -505,7 +988,7 @@ function McpAddPage() { · 7 unmodified lines | |||
| 505 | 988 | ||
| 506 | return ( | 989 | return ( |
| 507 | <McpSourceForm | 990 | <McpSourceForm |
| 508 | initialValue={defaultMcpInput()} | 991 | initialValue={initialValue} |
| 509 | mode="create" | 992 | mode="create" |
| 510 | onSubmit={async (input) => { | 993 | onSubmit={async (input) => { |
| 511 | const source = await createSource({ | 994 | const source = await createSource({ |
| @@ -562,13 +1045,13 @@ function McpEditPage(props: { · 50 unmodified lines | |||
| 562 | if (Result.isFailure(configResult)) { | 1045 | if (Result.isFailure(configResult)) { |
| 563 | return ( | 1046 | return ( |
| 564 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> | 1047 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> |
| 565 | Failed loading MCP config. | 1048 | Failed loading source configuration. |
| 566 | </div> | 1049 | </div> |
| 567 | ); | 1050 | ); |
| 568 | } | 1051 | } |
| 569 | 1052 | ||
| 570 | if (!Result.isSuccess(configResult)) { | 1053 | if (!Result.isSuccess(configResult)) { |
| 571 | return <div className="text-sm text-muted-foreground">Loading MCP config...</div>; | 1054 | return <div className="text-sm text-muted-foreground">Loading configuration...</div>; |
| 572 | } | 1055 | } |
| 573 | 1056 | ||
| 574 | return ( | 1057 | return ( |
| @@ -614,6 +1097,8 @@ function McpDetailPage(props: { · 39 unmodified lines | |||
| 614 | client.mutation("mcp", "removeSource"), | 1097 | client.mutation("mcp", "removeSource"), |
| 615 | { mode: "promise" }, | 1098 | { mode: "promise" }, |
| 616 | ); | 1099 | ); |
| 1100 | const [confirmDelete, setConfirmDelete] = useState(false); | ||
| 1101 | const [isDeleting, setIsDeleting] = useState(false); | ||
| 617 | const configResult = useAtomValue( | 1102 | const configResult = useAtomValue( |
| 618 | installation.status === "ready" | 1103 | installation.status === "ready" |
| 619 | ? client.query("mcp", "getSourceConfig", { | 1104 | ? client.query("mcp", "getSourceConfig", { |
| @@ -657,6 +1142,35 @@ function McpDetailPage(props: { · 37 unmodified lines | |||
| 657 | return <div className="text-sm text-muted-foreground">Loading workspace...</div>; | 1142 | return <div className="text-sm text-muted-foreground">Loading workspace...</div>; |
| 658 | } | 1143 | } |
| 659 | 1144 | ||
| 1145 | const handleDelete = async () => { | ||
| 1146 | if (isDeleting) { | ||
| 1147 | return; | ||
| 1148 | } | ||
| 1149 | |||
| 1150 | setIsDeleting(true); | ||
| 1151 | try { | ||
| 1152 | await removeSource({ | ||
| 1153 | path: { | ||
| 1154 | workspaceId: installation.data.scopeId, | ||
| 1155 | sourceId: props.source.id, | ||
| 1156 | }, | ||
| 1157 | reactivityKeys: { | ||
| 1158 | sources: [installation.data.scopeId], | ||
| 1159 | source: [installation.data.scopeId, props.source.id], | ||
| 1160 | sourceInspection: [installation.data.scopeId, props.source.id], | ||
| 1161 | sourceInspectionTool: [installation.data.scopeId, props.source.id], | ||
| 1162 | sourceDiscovery: [installation.data.scopeId, props.source.id], | ||
| 1163 | }, | ||
| 1164 | }); | ||
| 1165 | startTransition(() => { | ||
| 1166 | void navigation.home(); | ||
| 1167 | }); | ||
| 1168 | } finally { | ||
| 1169 | setIsDeleting(false); | ||
| 1170 | setConfirmDelete(false); | ||
| 1171 | } | ||
| 1172 | }; | ||
| 1173 | |||
| 660 | return ( | 1174 | return ( |
| 661 | <SourceToolExplorer | 1175 | <SourceToolExplorer |
| 662 | sourceId={props.source.id} | 1176 | sourceId={props.source.id} |
| @@ -676,36 +1190,40 @@ function McpDetailPage(props: { · 13 unmodified lines | |||
| 676 | <IconPencil className="size-3.5" /> | 1190 | <IconPencil className="size-3.5" /> |
| 677 | Edit | 1191 | Edit |
| 678 | </button> | 1192 | </button> |
| 679 | <button | 1193 | {confirmDelete ? ( |
| 680 | type="button" | 1194 | <div className="flex items-center gap-2"> |
| 681 | onClick={() => { | 1195 | <span className="text-xs font-medium text-destructive"> |
| 682 | const confirmed = window.confirm(`Delete MCP source "${props.source.name}"?`); | 1196 | Confirm delete? |
| 683 | if (!confirmed) { | 1197 | </span> |
| 684 | return; | 1198 | <button |
| 685 | } | 1199 | type="button" |
| 686 | 1200 | onClick={() => setConfirmDelete(false)} | |
| 687 | void removeSource({ | 1201 | disabled={isDeleting} |
| 688 | path: { | 1202 | className="inline-flex h-9 items-center rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50" |
| 689 | workspaceId: installation.data.scopeId, | 1203 | > |
| 690 | sourceId: props.source.id, | 1204 | Cancel |
| 691 | }, | 1205 | </button> |
| 692 | reactivityKeys: { | 1206 | <button |
| 693 | sources: [installation.data.scopeId], | 1207 | type="button" |
| 694 | source: [installation.data.scopeId, props.source.id], | 1208 | onClick={() => { |
| 695 | sourceInspection: [installation.data.scopeId, props.source.id], | 1209 | void handleDelete().catch(() => {}); |
| 696 | sourceInspectionTool: [installation.data.scopeId, props.source.id], | 1210 | }} |
| 697 | sourceDiscovery: [installation.data.scopeId, props.source.id], | 1211 | disabled={isDeleting} |
| 698 | }, | 1212 | className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" |
| 699 | }).then(() => { | 1213 | > |
| 700 | startTransition(() => { | 1214 | {isDeleting ? "Deleting..." : "Delete"} |
| 701 | void navigation.home(); | 1215 | </button> |
| 702 | }); | 1216 | </div> |
| 703 | }); | 1217 | ) : ( |
| 704 | }} | 1218 | <button |
| 705 | className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10" | 1219 | type="button" |
| 706 | > | 1220 | onClick={() => setConfirmDelete(true)} |
| 707 | Delete | 1221 | disabled={isDeleting} |
| 708 | </button> | 1222 | className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" |
| 1223 | > | ||
| 1224 | Delete | ||
| 1225 | </button> | ||
| 1226 | )} | ||
| 709 | </> | 1227 | </> |
| 710 | )} | 1228 | )} |
| 711 | /> | 1229 | /> |
| @@ -716,7 +1234,7 @@ const mcpSourceType = defineFrontendSourceType({ · 4 unmodified lines | |||
| 716 | key: "mcp", | 1234 | key: "mcp", |
| 717 | kind: "mcp", | 1235 | kind: "mcp", |
| 718 | displayName: "MCP", | 1236 | displayName: "MCP", |
| 719 | description: "Connect remote or local MCP servers with plugin-owned OAuth.", | 1237 | description: "Connect remote or local MCP servers.", |
| 720 | renderAddPage: McpAddPage, | 1238 | renderAddPage: McpAddPage, |
| 721 | renderEditPage: McpEditPage, | 1239 | renderEditPage: McpEditPage, |
| 722 | renderDetailPage: McpDetailPage, | 1240 | renderDetailPage: McpDetailPage, |
| @@ -19,6 +19,9 @@ import { · 18 unmodified lines | |||
| 19 | contentHash, | 19 | contentHash, |
| 20 | createCatalogImportMetadata, | 20 | createCatalogImportMetadata, |
| 21 | createSourceCatalogSyncResult, | 21 | createSourceCatalogSyncResult, |
| 22 | normalizeSourceDiscoveryUrl, | ||
| 23 | probeHeadersFromAuth, | ||
| 24 | type SourceDiscoveryResult, | ||
| 22 | } from "@executor/source-core"; | 25 | } from "@executor/source-core"; |
| 23 | import type { Source } from "@executor/platform-sdk/schema"; | 26 | import type { Source } from "@executor/platform-sdk/schema"; |
| 24 | import type { | 27 | import type { |
| @@ -41,6 +44,8 @@ import { · 16 unmodified lines | |||
| 41 | deriveMcpNamespace, | 44 | deriveMcpNamespace, |
| 42 | resolveMcpEndpoint, | 45 | resolveMcpEndpoint, |
| 43 | type McpConnectInput, | 46 | type McpConnectInput, |
| 47 | type McpDiscoverInput, | ||
| 48 | type McpDiscoverResult, | ||
| 44 | type McpConnectionAuth, | 49 | type McpConnectionAuth, |
| 45 | type McpOAuthPopupResult, | 50 | type McpOAuthPopupResult, |
| 46 | type McpOAuthSession, | 51 | type McpOAuthSession, |
| @@ -57,6 +62,9 @@ import { · 10 unmodified lines | |||
| 57 | createMcpCatalogFragment, | 62 | createMcpCatalogFragment, |
| 58 | type McpCatalogOperationInput, | 63 | type McpCatalogOperationInput, |
| 59 | } from "./catalog"; | 64 | } from "./catalog"; |
| 65 | import { | ||
| 66 | detectMcpSource, | ||
| 67 | } from "./discovery"; | ||
| 60 | import { | 68 | import { |
| 61 | createPooledMcpConnector, | 69 | createPooledMcpConnector, |
| 62 | } from "./connection-pool"; | 70 | } from "./connection-pool"; |
| @@ -124,6 +132,9 @@ export type McpSdk = { · 61 unmodified lines | |||
| 124 | getSourceConfig: ( | 132 | getSourceConfig: ( |
| 125 | sourceId: Source["id"], | 133 | sourceId: Source["id"], |
| 126 | ) => Effect.Effect<McpSourceConfigPayload, Error>; | 134 | ) => Effect.Effect<McpSourceConfigPayload, Error>; |
| 135 | discoverSource: ( | ||
| 136 | input: McpDiscoverInput, | ||
| 137 | ) => Effect.Effect<McpDiscoverResult, Error>; | ||
| 127 | createSource: ( | 138 | createSource: ( |
| 128 | input: McpConnectInput, | 139 | input: McpConnectInput, |
| 129 | ) => Effect.Effect<Source, Error>; | 140 | ) => Effect.Effect<Source, Error>; |
| @@ -328,6 +339,15 @@ const createMcpSourceSdk = ( · 198 unmodified lines | |||
| 328 | 339 | ||
| 329 | return sourceConfigFromStored(source, stored); | 340 | return sourceConfigFromStored(source, stored); |
| 330 | }), | 341 | }), |
| 342 | discoverSource: (input: McpDiscoverInput) => | ||
| 343 | Effect.gen(function* () { | ||
| 344 | const normalizedUrl = normalizeSourceDiscoveryUrl(input.endpoint); | ||
| 345 | const discovered = yield* detectMcpSource({ | ||
| 346 | normalizedUrl, | ||
| 347 | headers: probeHeadersFromAuth(input.probeAuth ?? null), | ||
| 348 | }); | ||
| 349 | return discovered satisfies SourceDiscoveryResult | null; | ||
| 350 | }), | ||
| 331 | createSource: (input: McpConnectInput) => | 351 | createSource: (input: McpConnectInput) => |
| 332 | Effect.gen(function* () { | 352 | Effect.gen(function* () { |
| 333 | const stored = yield* normalizeStoredSourceData(input); | 353 | const stored = yield* normalizeStoredSourceData(input); |
| @@ -908,6 +928,8 @@ export const mcpSdkPlugin = ( · 574 unmodified lines | |||
| 908 | return { | 928 | return { |
| 909 | getSourceConfig: (sourceId) => | 929 | getSourceConfig: (sourceId) => |
| 910 | provideRuntime(sourceSdk.getSourceConfig(sourceId)), | 930 | provideRuntime(sourceSdk.getSourceConfig(sourceId)), |
| 931 | discoverSource: (input) => | ||
| 932 | provideRuntime(sourceSdk.discoverSource(input)), | ||
| 911 | createSource: (input) => | 933 | createSource: (input) => |
| 912 | provideRuntime(sourceSdk.createSource(input)), | 934 | provideRuntime(sourceSdk.createSource(input)), |
| 913 | updateSource: (input) => | 935 | updateSource: (input) => |
| @@ -5,6 +5,8 @@ import { · 4 unmodified lines | |||
| 5 | } from "@executor/platform-sdk/schema"; | 5 | } from "@executor/platform-sdk/schema"; |
| 6 | import { | 6 | import { |
| 7 | JsonObjectSchema, | 7 | JsonObjectSchema, |
| 8 | SourceDiscoveryResultSchema, | ||
| 9 | SourceProbeAuthSchema, | ||
| 8 | SourceTransportSchema, | 10 | SourceTransportSchema, |
| 9 | StringArraySchema, | 11 | StringArraySchema, |
| 10 | StringMapSchema, | 12 | StringMapSchema, |
| @@ -70,6 +72,13 @@ export const McpStartOAuthInputSchema = Schema.Struct({ · 59 unmodified lines | |||
| 70 | redirectUrl: Schema.String, | 72 | redirectUrl: Schema.String, |
| 71 | }); | 73 | }); |
| 72 | 74 | ||
| 75 | export const McpDiscoverInputSchema = Schema.Struct({ | ||
| 76 | endpoint: Schema.String, | ||
| 77 | probeAuth: Schema.optional(Schema.NullOr(SourceProbeAuthSchema)), | ||
| 78 | }); | ||
| 79 | |||
| 80 | export const McpDiscoverResultSchema = Schema.NullOr(SourceDiscoveryResultSchema); | ||
| 81 | |||
| 73 | export const McpStartOAuthResultSchema = Schema.Struct({ | 82 | export const McpStartOAuthResultSchema = Schema.Struct({ |
| 74 | sessionId: Schema.String, | 83 | sessionId: Schema.String, |
| 75 | authorizationUrl: Schema.String, | 84 | authorizationUrl: Schema.String, |
| @@ -123,6 +132,8 @@ export type McpUpdateSourceInput = typeof McpUpdateSourceInputSchema.Type; · 47 unmodified lines | |||
| 123 | export type McpStoredSourceData = typeof McpStoredSourceDataSchema.Type; | 132 | export type McpStoredSourceData = typeof McpStoredSourceDataSchema.Type; |
| 124 | export type McpStartOAuthInput = typeof McpStartOAuthInputSchema.Type; | 133 | export type McpStartOAuthInput = typeof McpStartOAuthInputSchema.Type; |
| 125 | export type McpStartOAuthResult = typeof McpStartOAuthResultSchema.Type; | 134 | export type McpStartOAuthResult = typeof McpStartOAuthResultSchema.Type; |
| 135 | export type McpDiscoverInput = typeof McpDiscoverInputSchema.Type; | ||
| 136 | export type McpDiscoverResult = typeof McpDiscoverResultSchema.Type; | ||
| 126 | export type McpOAuthSession = typeof McpOAuthSessionSchema.Type; | 137 | export type McpOAuthSession = typeof McpOAuthSessionSchema.Type; |
| 127 | export type McpOAuthPopupResult = typeof McpOAuthPopupResultSchema.Type; | 138 | export type McpOAuthPopupResult = typeof McpOAuthPopupResultSchema.Type; |
| 128 | 139 | ||
| @@ -9,8 +9,10 @@ import { · 8 unmodified lines | |||
| 9 | defineExecutorPluginHttpApiClient, | 9 | defineExecutorPluginHttpApiClient, |
| 10 | useAtomValue, | 10 | useAtomValue, |
| 11 | useAtomSet, | 11 | useAtomSet, |
| 12 | useCreateSecret, | ||
| 12 | useExecutorMutation, | 13 | useExecutorMutation, |
| 13 | usePrefetchToolDetail, | 14 | usePrefetchToolDetail, |
| 15 | useSecrets, | ||
| 14 | useSourceDiscovery, | 16 | useSourceDiscovery, |
| 15 | useSourceInspection, | 17 | useSourceInspection, |
| 16 | useSourceToolDetail, | 18 | useSourceToolDetail, |
| @@ -62,6 +64,93 @@ const defaultOpenApiInput = (): OpenApiConnectInput => ({ · 45 unmodified lines | |||
| 62 | }, | 64 | }, |
| 63 | }); | 65 | }); |
| 64 | 66 | ||
| 67 | const DEFAULT_BEARER_HEADER_NAME = "Authorization"; | ||
| 68 | const DEFAULT_BEARER_PREFIX = "Bearer "; | ||
| 69 | const CREATE_SECRET_VALUE = "__create_openapi_secret__"; | ||
| 70 | |||
| 71 | const presetString = ( | ||
| 72 | search: Record<string, unknown>, | ||
| 73 | key: string, | ||
| 74 | ): string | null => { | ||
| 75 | const value = search[key]; | ||
| 76 | return typeof value === "string" && value.trim().length > 0 | ||
| 77 | ? value.trim() | ||
| 78 | : null; | ||
| 79 | }; | ||
| 80 | |||
| 81 | const firstSearchString = ( | ||
| 82 | search: Record<string, unknown>, | ||
| 83 | keys: ReadonlyArray<string>, | ||
| 84 | ): string | null => { | ||
| 85 | for (const key of keys) { | ||
| 86 | const value = presetString(search, key); | ||
| 87 | if (value) { | ||
| 88 | return value; | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | return null; | ||
| 93 | }; | ||
| 94 | |||
| 95 | const looksLikeOpenApiSpecUrl = (value: string): boolean => { | ||
| 96 | const lower = value.trim().toLowerCase(); | ||
| 97 | if (!/^https?:\/\//.test(lower)) { | ||
| 98 | return false; | ||
| 99 | } | ||
| 100 | |||
| 101 | return ( | ||
| 102 | lower.endsWith(".json") | ||
| 103 | || lower.endsWith(".yaml") | ||
| 104 | || lower.endsWith(".yml") | ||
| 105 | || lower.includes("/openapi") | ||
| 106 | || lower.includes("openapi.") | ||
| 107 | || lower.includes("/swagger") | ||
| 108 | || lower.includes("swagger.") | ||
| 109 | || lower.includes("/api-docs") | ||
| 110 | ); | ||
| 111 | }; | ||
| 112 | |||
| 113 | const inferOpenApiUrls = (value: string): { | ||
| 114 | specUrl: string; | ||
| 115 | baseUrl: string | null; | ||
| 116 | } => { | ||
| 117 | const trimmed = value.trim(); | ||
| 118 | if (!trimmed) { | ||
| 119 | return { | ||
| 120 | specUrl: defaultOpenApiInput().specUrl, | ||
| 121 | baseUrl: defaultOpenApiInput().baseUrl, | ||
| 122 | }; | ||
| 123 | } | ||
| 124 | |||
| 125 | if (looksLikeOpenApiSpecUrl(trimmed)) { | ||
| 126 | return { | ||
| 127 | specUrl: trimmed, | ||
| 128 | baseUrl: null, | ||
| 129 | }; | ||
| 130 | } | ||
| 131 | |||
| 132 | const normalizedBaseUrl = trimmed.replace(/\/+$/, ""); | ||
| 133 | return { | ||
| 134 | specUrl: `${normalizedBaseUrl}/openapi.json`, | ||
| 135 | baseUrl: normalizedBaseUrl, | ||
| 136 | }; | ||
| 137 | }; | ||
| 138 | |||
| 139 | const openApiInputFromSearch = ( | ||
| 140 | search: Record<string, unknown>, | ||
| 141 | ): OpenApiConnectInput => { | ||
| 142 | const defaults = defaultOpenApiInput(); | ||
| 143 | const genericUrl = firstSearchString(search, ["inputUrl", "pastedUrl", "url"]); | ||
| 144 | const inferred = genericUrl ? inferOpenApiUrls(genericUrl) : defaults; | ||
| 145 | |||
| 146 | return { | ||
| 147 | ...defaults, | ||
| 148 | name: presetString(search, "presetName") ?? defaults.name, | ||
| 149 | specUrl: presetString(search, "presetSpecUrl") ?? inferred.specUrl, | ||
| 150 | baseUrl: presetString(search, "presetBaseUrl") ?? inferred.baseUrl, | ||
| 151 | }; | ||
| 152 | }; | ||
| 153 | |||
| 65 | const getOpenApiHttpClient = defineExecutorPluginHttpApiClient<"OpenApiReactHttpClient">()( | 154 | const getOpenApiHttpClient = defineExecutorPluginHttpApiClient<"OpenApiReactHttpClient">()( |
| 66 | "OpenApiReactHttpClient", | 155 | "OpenApiReactHttpClient", |
| 67 | [openApiHttpApiExtension] as const, | 156 | [openApiHttpApiExtension] as const, |
| @@ -72,13 +161,8 @@ const Section = (props: { · 4 unmodified lines | |||
| 72 | title: string; | 161 | title: string; |
| 73 | children: ReactNode; | 162 | children: ReactNode; |
| 74 | }) => ( | 163 | }) => ( |
| 75 | <section className="rounded-xl border border-border/70 bg-card/40 p-4"> | 164 | <section> |
| 76 | <div className="mb-3 flex items-center gap-2"> | 165 | <h2 className="mb-3 text-sm font-semibold text-foreground">{props.title}</h2> |
| 77 | <h2 className="text-sm font-semibold">{props.title}</h2> | ||
| 78 | <span className="inline-flex items-center rounded-full border border-transparent bg-secondary px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-secondary-foreground"> | ||
| 79 | Plugin | ||
| 80 | </span> | ||
| 81 | </div> | ||
| 82 | {props.children} | 166 | {props.children} |
| 83 | </section> | 167 | </section> |
| 84 | ); | 168 | ); |
| @@ -174,9 +258,26 @@ const secretValue = (input: OpenApiConnectInput["auth"]): string => · 89 unmodified lines | |||
| 174 | ? JSON.stringify(input.tokenSecretRef) | 258 | ? JSON.stringify(input.tokenSecretRef) |
| 175 | : ""; | 259 | : ""; |
| 176 | 260 | ||
| 261 | const bearerHeaderNameValue = (input: OpenApiConnectInput["auth"]): string => | ||
| 262 | input.kind === "bearer" | ||
| 263 | ? input.headerName?.trim() || DEFAULT_BEARER_HEADER_NAME | ||
| 264 | : DEFAULT_BEARER_HEADER_NAME; | ||
| 265 | |||
| 266 | const bearerPrefixValue = (input: OpenApiConnectInput["auth"]): string => | ||
| 267 | input.kind === "bearer" | ||
| 268 | ? input.prefix ?? DEFAULT_BEARER_PREFIX | ||
| 269 | : DEFAULT_BEARER_PREFIX; | ||
| 270 | |||
| 271 | const trimToNull = (value: string): string | null => { | ||
| 272 | const trimmed = value.trim(); | ||
| 273 | return trimmed.length > 0 ? trimmed : null; | ||
| 274 | }; | ||
| 275 | |||
| 177 | const authFromSecretValue = ( | 276 | const authFromSecretValue = ( |
| 178 | authKind: OpenApiConnectInput["auth"]["kind"], | 277 | authKind: OpenApiConnectInput["auth"]["kind"], |
| 179 | value: string, | 278 | value: string, |
| 279 | headerName: string, | ||
| 280 | prefix: string, | ||
| 180 | ): OpenApiConnectInput["auth"] => { | 281 | ): OpenApiConnectInput["auth"] => { |
| 181 | if (authKind === "none") { | 282 | if (authKind === "none") { |
| 182 | return { | 283 | return { |
| @@ -191,23 +292,138 @@ const authFromSecretValue = ( · 8 unmodified lines | |||
| 191 | return { | 292 | return { |
| 192 | kind: "bearer", | 293 | kind: "bearer", |
| 193 | tokenSecretRef: JSON.parse(value) as OpenApiConnectInput["auth"] & { tokenSecretRef: never }["tokenSecretRef"], | 294 | tokenSecretRef: JSON.parse(value) as OpenApiConnectInput["auth"] & { tokenSecretRef: never }["tokenSecretRef"], |
| 295 | headerName: trimToNull(headerName), | ||
| 296 | prefix: prefix === DEFAULT_BEARER_PREFIX ? null : prefix, | ||
| 194 | }; | 297 | }; |
| 195 | }; | 298 | }; |
| 196 | 299 | ||
| 197 | const useAvailableSecrets = ( | 300 | function SecretSelectOrCreateField(props: { |
| 198 | openApiHttpClient: ReturnType<typeof getOpenApiHttpClient>, | 301 | label: string; |
| 199 | ) => { | 302 | value: string; |
| 200 | const secretsResult = useAtomValue( | 303 | emptyLabel: string; |
| 201 | openApiHttpClient.query("local", "listSecrets", { | 304 | draftNamePlaceholder: string; |
| 202 | reactivityKeys: { | 305 | draftValuePlaceholder: string; |
| 203 | secrets: [], | 306 | onChange: (value: string) => void; |
| 204 | }, | 307 | }) { |
| 205 | timeToLive: "1 minute", | 308 | const secrets = useSecrets(); |
| 206 | }), | 309 | const createSecret = useCreateSecret(); |
| 207 | ); | 310 | const [draftName, setDraftName] = useState(""); |
| 311 | const [draftValue, setDraftValue] = useState(""); | ||
| 312 | const [createError, setCreateError] = useState<string | null>(null); | ||
| 313 | const [showCreate, setShowCreate] = useState(false); | ||
| 314 | |||
| 315 | const handleSelectChange = (nextValue: string) => { | ||
| 316 | if (nextValue === CREATE_SECRET_VALUE) { | ||
| 317 | setShowCreate(true); | ||
| 318 | props.onChange(""); | ||
| 319 | return; | ||
| 320 | } | ||
| 208 | 321 | ||
| 209 | return Result.isSuccess(secretsResult) ? secretsResult.value : []; | 322 | setShowCreate(false); |
| 210 | }; | 323 | setCreateError(null); |
| 324 | props.onChange(nextValue); | ||
| 325 | }; | ||
| 326 | |||
| 327 | const handleCreate = async () => { | ||
| 328 | const trimmedName = draftName.trim(); | ||
| 329 | if (!trimmedName) { | ||
| 330 | setCreateError("Secret name is required."); | ||
| 331 | return; | ||
| 332 | } | ||
| 333 | if (!draftValue.trim()) { | ||
| 334 | setCreateError("Secret value is required."); | ||
| 335 | return; | ||
| 336 | } | ||
| 337 | |||
| 338 | try { | ||
| 339 | setCreateError(null); | ||
| 340 | const created = await createSecret.mutateAsync({ | ||
| 341 | name: trimmedName, | ||
| 342 | value: draftValue, | ||
| 343 | }); | ||
| 344 | props.onChange(JSON.stringify({ | ||
| 345 | providerId: created.providerId, | ||
| 346 | handle: created.id, | ||
| 347 | })); | ||
| 348 | setDraftName(""); | ||
| 349 | setDraftValue(""); | ||
| 350 | setShowCreate(false); | ||
| 351 | } catch (cause) { | ||
| 352 | setCreateError(cause instanceof Error ? cause.message : "Failed creating secret."); | ||
| 353 | } | ||
| 354 | }; | ||
| 355 | |||
| 356 | return ( | ||
| 357 | <div className="grid gap-2"> | ||
| 358 | <span className="text-xs font-medium text-foreground">{props.label}</span> | ||
| 359 | <select | ||
| 360 | value={showCreate ? CREATE_SECRET_VALUE : props.value} | ||
| 361 | onChange={(event) => handleSelectChange(event.target.value)} | ||
| 362 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 363 | > | ||
| 364 | <option value="">{props.emptyLabel}</option> | ||
| 365 | {secrets.status === "ready" && | ||
| 366 | secrets.data.map((secret) => ( | ||
| 367 | <option | ||
| 368 | key={`${secret.providerId}:${secret.id}`} | ||
| 369 | value={JSON.stringify({ | ||
| 370 | providerId: secret.providerId, | ||
| 371 | handle: secret.id, | ||
| 372 | })} | ||
| 373 | > | ||
| 374 | {secret.name ?? secret.id} | ||
| 375 | </option> | ||
| 376 | ))} | ||
| 377 | <option value={CREATE_SECRET_VALUE}>Create new secret</option> | ||
| 378 | </select> | ||
| 379 | |||
| 380 | {showCreate && ( | ||
| 381 | <div className="space-y-3 border-l-2 border-border pl-4"> | ||
| 382 | <input | ||
| 383 | value={draftName} | ||
| 384 | onChange={(event) => setDraftName(event.target.value)} | ||
| 385 | placeholder={props.draftNamePlaceholder} | ||
| 386 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 387 | /> | ||
| 388 | <textarea | ||
| 389 | value={draftValue} | ||
| 390 | onChange={(event) => setDraftValue(event.target.value)} | ||
| 391 | rows={3} | ||
| 392 | placeholder={props.draftValuePlaceholder} | ||
| 393 | className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 394 | /> | ||
| 395 | {createError && ( | ||
| 396 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive"> | ||
| 397 | {createError} | ||
| 398 | </div> | ||
| 399 | )} | ||
| 400 | <div className="flex items-center gap-2"> | ||
| 401 | <button | ||
| 402 | type="button" | ||
| 403 | onClick={() => { | ||
| 404 | void handleCreate(); | ||
| 405 | }} | ||
| 406 | disabled={createSecret.status === "pending"} | ||
| 407 | className="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50" | ||
| 408 | > | ||
| 409 | {createSecret.status === "pending" ? "Creating..." : "Create Secret"} | ||
| 410 | </button> | ||
| 411 | <button | ||
| 412 | type="button" | ||
| 413 | onClick={() => { | ||
| 414 | setShowCreate(false); | ||
| 415 | setCreateError(null); | ||
| 416 | }} | ||
| 417 | className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50" | ||
| 418 | > | ||
| 419 | Cancel | ||
| 420 | </button> | ||
| 421 | </div> | ||
| 422 | </div> | ||
| 423 | )} | ||
| 424 | </div> | ||
| 425 | ); | ||
| 426 | } | ||
| 211 | 427 | ||
| 212 | function OpenApiSourceForm(props: { | 428 | function OpenApiSourceForm(props: { |
| 213 | mode: "create" | "edit"; | 429 | mode: "create" | "edit"; |
| @@ -217,7 +433,6 @@ function OpenApiSourceForm(props: { · 3 unmodified lines | |||
| 217 | onSubmit: (input: OpenApiConnectInput) => Promise<void>; | 433 | onSubmit: (input: OpenApiConnectInput) => Promise<void>; |
| 218 | }) { | 434 | }) { |
| 219 | const openApiHttpClient = getOpenApiHttpClient(); | 435 | const openApiHttpClient = getOpenApiHttpClient(); |
| 220 | const availableSecrets = useAvailableSecrets(openApiHttpClient); | ||
| 221 | const previewDocument = useAtomSet( | 436 | const previewDocument = useAtomSet( |
| 222 | openApiHttpClient.mutation("openapi", "previewDocument"), | 437 | openApiHttpClient.mutation("openapi", "previewDocument"), |
| 223 | { mode: "promise" }, | 438 | { mode: "promise" }, |
| @@ -232,6 +447,12 @@ function OpenApiSourceForm(props: { · 8 unmodified lines | |||
| 232 | const [tokenSecretRef, setTokenSecretRef] = useState( | 447 | const [tokenSecretRef, setTokenSecretRef] = useState( |
| 233 | secretValue(props.initialValue.auth), | 448 | secretValue(props.initialValue.auth), |
| 234 | ); | 449 | ); |
| 450 | const [authHeaderName, setAuthHeaderName] = useState( | ||
| 451 | bearerHeaderNameValue(props.initialValue.auth), | ||
| 452 | ); | ||
| 453 | const [authPrefix, setAuthPrefix] = useState( | ||
| 454 | bearerPrefixValue(props.initialValue.auth), | ||
| 455 | ); | ||
| 235 | const [error, setError] = useState<string | null>(null); | 456 | const [error, setError] = useState<string | null>(null); |
| 236 | const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null); | 457 | const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null); |
| 237 | const [nameEdited, setNameEdited] = useState(false); | 458 | const [nameEdited, setNameEdited] = useState(false); |
| @@ -332,7 +553,12 @@ function OpenApiSourceForm(props: { · 94 unmodified lines | |||
| 332 | } | 553 | } |
| 333 | 554 | ||
| 334 | try { | 555 | try { |
| 335 | const auth = authFromSecretValue(authKind, tokenSecretRef); | 556 | const auth = authFromSecretValue( |
| 557 | authKind, | ||
| 558 | tokenSecretRef, | ||
| 559 | authHeaderName, | ||
| 560 | authPrefix, | ||
| 561 | ); | ||
| 336 | await submitMutation.mutateAsync({ | 562 | await submitMutation.mutateAsync({ |
| 337 | name: trimmedName, | 563 | name: trimmedName, |
| 338 | specUrl: trimmedSpecUrl, | 564 | specUrl: trimmedSpecUrl, |
| @@ -345,15 +571,11 @@ function OpenApiSourceForm(props: { · 6 unmodified lines | |||
| 345 | }; | 571 | }; |
| 346 | 572 | ||
| 347 | return ( | 573 | return ( |
| 348 | <div className="space-y-4"> | 574 | <div className="space-y-6 rounded-xl border border-border p-6"> |
| 349 | <Section title="Connection"> | 575 | <Section title="Connection"> |
| 350 | <p className="text-sm text-muted-foreground"> | 576 | <div className="grid gap-4"> |
| 351 | This plugin owns its typed HTTP client and source payload shape. The app shell only | 577 | <label className="grid gap-2"> |
| 352 | mounts the registered page. | 578 | <span className="text-xs font-medium text-foreground">Name</span> |
| 353 | </p> | ||
| 354 | <div className="mt-4 grid gap-4"> | ||
| 355 | <label className="grid gap-1.5"> | ||
| 356 | <span className="text-[12px] font-medium text-foreground">Name</span> | ||
| 357 | <input | 579 | <input |
| 358 | value={name} | 580 | value={name} |
| 359 | onChange={(event) => { | 581 | onChange={(event) => { |
| @@ -361,12 +583,12 @@ function OpenApiSourceForm(props: { · 1 unmodified line | |||
| 361 | setName(event.target.value); | 583 | setName(event.target.value); |
| 362 | }} | 584 | }} |
| 363 | placeholder="GitHub REST" | 585 | placeholder="GitHub REST" |
| 364 | className="h-10 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" | 586 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 365 | /> | 587 | /> |
| 366 | </label> | 588 | </label> |
| 367 | 589 | ||
| 368 | <label className="grid gap-1.5"> | 590 | <label className="grid gap-2"> |
| 369 | <span className="text-[12px] font-medium text-foreground">Spec URL</span> | 591 | <span className="text-xs font-medium text-foreground">Spec URL</span> |
| 370 | <input | 592 | <input |
| 371 | value={specUrl} | 593 | value={specUrl} |
| 372 | onChange={(event) => setSpecUrl(event.target.value)} | 594 | onChange={(event) => setSpecUrl(event.target.value)} |
| @@ -374,12 +596,12 @@ function OpenApiSourceForm(props: { · 1 unmodified line | |||
| 374 | void runPreview({ mode: "manual" }); | 596 | void runPreview({ mode: "manual" }); |
| 375 | }} | 597 | }} |
| 376 | placeholder="https://example.com/openapi.json" | 598 | placeholder="https://example.com/openapi.json" |
| 377 | className="h-10 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" | 599 | className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 378 | /> | 600 | /> |
| 379 | </label> | 601 | </label> |
| 380 | 602 | ||
| 381 | <label className="grid gap-1.5"> | 603 | <label className="grid gap-2"> |
| 382 | <span className="text-[12px] font-medium text-foreground">Base URL</span> | 604 | <span className="text-xs font-medium text-foreground">Base URL</span> |
| 383 | <input | 605 | <input |
| 384 | value={baseUrl} | 606 | value={baseUrl} |
| 385 | onChange={(event) => { | 607 | onChange={(event) => { |
| @@ -387,17 +609,17 @@ function OpenApiSourceForm(props: { · 1 unmodified line | |||
| 387 | setBaseUrl(event.target.value); | 609 | setBaseUrl(event.target.value); |
| 388 | }} | 610 | }} |
| 389 | placeholder="https://api.example.com" | 611 | placeholder="https://api.example.com" |
| 390 | className="h-10 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" | 612 | className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 391 | /> | 613 | /> |
| 392 | </label> | 614 | </label> |
| 393 | 615 | ||
| 394 | <label className="grid gap-1.5"> | 616 | <label className="grid gap-2"> |
| 395 | <span className="text-[12px] font-medium text-foreground">Auth</span> | 617 | <span className="text-xs font-medium text-foreground">Auth</span> |
| 396 | <select | 618 | <select |
| 397 | value={authKind} | 619 | value={authKind} |
| 398 | onChange={(event) => | 620 | onChange={(event) => |
| 399 | setAuthKind(event.target.value as OpenApiConnectInput["auth"]["kind"])} | 621 | setAuthKind(event.target.value as OpenApiConnectInput["auth"]["kind"])} |
| 400 | className="h-10 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" | 622 | className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 401 | > | 623 | > |
| 402 | <option value="none">None</option> | 624 | <option value="none">None</option> |
| 403 | <option value="bearer">Bearer Secret</option> | 625 | <option value="bearer">Bearer Secret</option> |
| @@ -405,52 +627,54 @@ function OpenApiSourceForm(props: { · 1 unmodified line | |||
| 405 | </label> | 627 | </label> |
| 406 | 628 | ||
| 407 | {authKind === "bearer" && ( | 629 | {authKind === "bearer" && ( |
| 408 | <label className="grid gap-1.5"> | 630 | <div className="space-y-4 border-l-2 border-border pl-4"> |
| 409 | <span className="text-[12px] font-medium text-foreground">Secret</span> | 631 | <SecretSelectOrCreateField |
| 410 | <select | 632 | label="Secret" |
| 411 | value={tokenSecretRef} | 633 | value={tokenSecretRef} |
| 412 | onChange={(event) => setTokenSecretRef(event.target.value)} | 634 | emptyLabel="Select a secret" |
| 413 | className="h-10 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" | 635 | draftNamePlaceholder="OpenAPI bearer token" |
| 414 | > | 636 | draftValuePlaceholder="Paste the token value" |
| 415 | <option value="">Select a secret</option> | 637 | onChange={setTokenSecretRef} |
| 416 | {availableSecrets.map((secret) => ( | 638 | /> |
| 417 | <option | 639 | |
| 418 | key={`${secret.providerId}:${secret.id}`} | 640 | <div className="grid gap-4 sm:grid-cols-2"> |
| 419 | value={JSON.stringify({ | 641 | <label className="grid gap-2"> |
| 420 | providerId: secret.providerId, | 642 | <span className="text-xs font-medium text-foreground">Header Name</span> |
| 421 | handle: secret.id, | 643 | <input |
| 422 | })} | 644 | value={authHeaderName} |
| 423 | > | 645 | onChange={(event) => setAuthHeaderName(event.target.value)} |
| 424 | {secret.name || secret.id} | 646 | placeholder={DEFAULT_BEARER_HEADER_NAME} |
| 425 | </option> | 647 | className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 426 | ))} | 648 | /> |
| 427 | </select> | 649 | </label> |
| 428 | </label> | 650 | |
| 651 | <label className="grid gap-2"> | ||
| 652 | <span className="text-xs font-medium text-foreground">Prefix</span> | ||
| 653 | <input | ||
| 654 | value={authPrefix} | ||
| 655 | onChange={(event) => setAuthPrefix(event.target.value)} | ||
| 656 | placeholder={DEFAULT_BEARER_PREFIX} | ||
| 657 | className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" | ||
| 658 | /> | ||
| 659 | </label> | ||
| 660 | </div> | ||
| 661 | </div> | ||
| 429 | )} | 662 | )} |
| 430 | </div> | 663 | </div> |
| 431 | </Section> | 664 | </Section> |
| 432 | 665 | ||
| 433 | <Section title="Preview"> | 666 | <Section title="Preview"> |
| 434 | <p className="text-sm text-muted-foreground"> | 667 | <div className="flex items-center gap-3"> |
| 435 | Preview introspects the document and can pull out defaults like title and base URL from | ||
| 436 | the OpenAPI spec. | ||
| 437 | </p> | ||
| 438 | <div className="mt-3 flex items-center gap-3"> | ||
| 439 | <button | 668 | <button |
| 440 | type="button" | 669 | type="button" |
| 441 | onClick={() => { | 670 | onClick={() => { |
| 442 | void runPreview({ mode: "manual" }); | 671 | void runPreview({ mode: "manual" }); |
| 443 | }} | 672 | }} |
| 444 | disabled={previewMutation.status === "pending" || submitMutation.status === "pending"} | 673 | disabled={previewMutation.status === "pending" || submitMutation.status === "pending"} |
| 445 | className="inline-flex h-7 items-center justify-center rounded-md border border-input bg-transparent px-2.5 text-xs font-medium text-foreground disabled:pointer-events-none disabled:opacity-50" | 674 | className="inline-flex h-9 items-center justify-center rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50" |
| 446 | > | 675 | > |
| 447 | {previewMutation.status === "pending" ? "Previewing..." : "Preview OpenAPI Document"} | 676 | {previewMutation.status === "pending" ? "Previewing..." : "Preview Spec"} |
| 448 | </button> | 677 | </button> |
| 449 | {previewMutation.status === "pending" && ( | ||
| 450 | <div className="text-xs text-muted-foreground"> | ||
| 451 | Inferring from spec... | ||
| 452 | </div> | ||
| 453 | )} | ||
| 454 | {preview && ( | 678 | {preview && ( |
| 455 | <div className="text-xs text-muted-foreground"> | 679 | <div className="text-xs text-muted-foreground"> |
| 456 | {preview.operationCount} operations | 680 | {preview.operationCount} operations |
| @@ -459,35 +683,31 @@ function OpenApiSourceForm(props: { · 2 unmodified lines | |||
| 459 | )} | 683 | )} |
| 460 | </div> | 684 | </div> |
| 461 | {preview && ( | 685 | {preview && ( |
| 462 | <div className="mt-4 rounded-lg border border-border/70 bg-background/60 p-4 text-sm"> | 686 | <div className="mt-3 rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm"> |
| 463 | <div className="grid gap-2"> | 687 | <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs"> |
| 464 | <div> | 688 | <dt className="font-medium text-foreground">Title</dt> |
| 465 | <span className="font-medium text-foreground">Title:</span>{" "} | 689 | <dd className="text-muted-foreground">{preview.title ?? "—"}</dd> |
| 466 | <span className="text-muted-foreground">{preview.title ?? "Unknown"}</span> | 690 | <dt className="font-medium text-foreground">Version</dt> |
| 467 | </div> | 691 | <dd className="text-muted-foreground">{preview.version ?? "—"}</dd> |
| 468 | <div> | 692 | <dt className="font-medium text-foreground">Base URL</dt> |
| 469 | <span className="font-medium text-foreground">Version:</span>{" "} | 693 | <dd className="font-mono text-muted-foreground">{preview.baseUrl ?? "—"}</dd> |
| 470 | <span className="text-muted-foreground">{preview.version ?? "Unknown"}</span> | 694 | {preview.namespace && ( |
| 471 | </div> | 695 | <> |
| 472 | <div> | 696 | <dt className="font-medium text-foreground">Namespace</dt> |
| 473 | <span className="font-medium text-foreground">Base URL:</span>{" "} | 697 | <dd className="text-muted-foreground">{preview.namespace}</dd> |
| 474 | <span className="text-muted-foreground">{preview.baseUrl ?? "Not declared"}</span> | 698 | </> |
| 475 | </div> | 699 | )} |
| 476 | <div> | 700 | {preview.securitySchemes.length > 0 && ( |
| 477 | <span className="font-medium text-foreground">Namespace:</span>{" "} | 701 | <> |
| 478 | <span className="text-muted-foreground">{preview.namespace ?? "Not inferred"}</span> | 702 | <dt className="font-medium text-foreground">Auth</dt> |
| 479 | </div> | 703 | <dd className="text-muted-foreground"> |
| 480 | <div> | 704 | {preview.securitySchemes |
| 481 | <span className="font-medium text-foreground">Auth:</span>{" "} | 705 | .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`) |
| 482 | <span className="text-muted-foreground"> | 706 | .join(", ")} |
| 483 | {preview.securitySchemes.length > 0 | 707 | </dd> |
| 484 | ? preview.securitySchemes | 708 | </> |
| 485 | .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`) | 709 | )} |
| 486 | .join(", ") | 710 | </dl> |
| 487 | : "No declared auth schemes"} | ||
| 488 | </span> | ||
| 489 | </div> | ||
| 490 | </div> | ||
| 491 | {preview.warnings.length > 0 && ( | 711 | {preview.warnings.length > 0 && ( |
| 492 | <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800"> | 712 | <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800"> |
| 493 | {preview.warnings.join(" ")} | 713 | {preview.warnings.join(" ")} |
| @@ -497,38 +717,31 @@ function OpenApiSourceForm(props: { · 3 unmodified lines | |||
| 497 | )} | 717 | )} |
| 498 | </Section> | 718 | </Section> |
| 499 | 719 | ||
| 500 | <Section title={props.mode === "create" ? "Submit" : "Save"}> | 720 | {error && ( |
| 501 | {error && ( | 721 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive"> |
| 502 | <div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive"> | 722 | {error} |
| 503 | {error} | ||
| 504 | </div> | ||
| 505 | )} | ||
| 506 | <div className="flex items-center gap-3"> | ||
| 507 | <button | ||
| 508 | type="button" | ||
| 509 | onClick={() => { | ||
| 510 | void handleSubmit(); | ||
| 511 | }} | ||
| 512 | disabled={submitMutation.status === "pending"} | ||
| 513 | className="inline-flex h-7 items-center justify-center rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground disabled:pointer-events-none disabled:opacity-50" | ||
| 514 | > | ||
| 515 | {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel} | ||
| 516 | </button> | ||
| 517 | <div className="text-xs text-muted-foreground"> | ||
| 518 | {props.mode === "create" | ||
| 519 | ? "Creates a real source and immediately imports its tools." | ||
| 520 | : "Updates plugin-owned config and refreshes the imported model."} | ||
| 521 | </div> | ||
| 522 | </div> | 723 | </div> |
| 523 | </Section> | 724 | )} |
| 725 | |||
| 726 | <div className="flex items-center justify-end"> | ||
| 727 | <button | ||
| 728 | type="button" | ||
| 729 | onClick={() => { | ||
| 730 | void handleSubmit(); | ||
| 731 | }} | ||
| 732 | disabled={submitMutation.status === "pending"} | ||
| 733 | className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50" | ||
| 734 | > | ||
| 735 | {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel} | ||
| 736 | </button> | ||
| 737 | </div> | ||
| 524 | </div> | 738 | </div> |
| 525 | ); | 739 | ); |
| 526 | } | 740 | } |
| 527 | 741 | ||
| 528 | function OpenApiAddSourcePage(props: { | 742 | function OpenApiAddSourcePage() { |
| 529 | initialValue: OpenApiConnectInput; | ||
| 530 | }) { | ||
| 531 | const navigation = useSourcePluginNavigation(); | 743 | const navigation = useSourcePluginNavigation(); |
| 744 | const initialValue = openApiInputFromSearch(useSourcePluginSearch()); | ||
| 532 | const openApiHttpClient = getOpenApiHttpClient(); | 745 | const openApiHttpClient = getOpenApiHttpClient(); |
| 533 | const createSource = useAtomSet( | 746 | const createSource = useAtomSet( |
| 534 | openApiHttpClient.mutation("openapi", "createSource"), | 747 | openApiHttpClient.mutation("openapi", "createSource"), |
| @@ -539,7 +752,7 @@ function OpenApiAddSourcePage(props: { · 4 unmodified lines | |||
| 539 | return ( | 752 | return ( |
| 540 | <OpenApiSourceForm | 753 | <OpenApiSourceForm |
| 541 | mode="create" | 754 | mode="create" |
| 542 | initialValue={props.initialValue} | 755 | initialValue={initialValue} |
| 543 | submitLabel="Create Source" | 756 | submitLabel="Create Source" |
| 544 | busyLabel="Creating..." | 757 | busyLabel="Creating..." |
| 545 | onSubmit={async (payload) => { | 758 | onSubmit={async (payload) => { |
| @@ -587,17 +800,17 @@ function OpenApiEditSourcePage(props: { · 41 unmodified lines | |||
| 587 | if (!Result.isSuccess(configResult)) { | 800 | if (!Result.isSuccess(configResult)) { |
| 588 | if (Result.isFailure(configResult)) { | 801 | if (Result.isFailure(configResult)) { |
| 589 | return ( | 802 | return ( |
| 590 | <Section title="OpenAPI Plugin Editor"> | 803 | <Section title="Edit Source"> |
| 591 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 p-4 text-sm text-destructive"> | 804 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 p-4 text-sm text-destructive"> |
| 592 | Failed loading plugin config. | 805 | Failed loading source configuration. |
| 593 | </div> | 806 | </div> |
| 594 | </Section> | 807 | </Section> |
| 595 | ); | 808 | ); |
| 596 | } | 809 | } |
| 597 | 810 | ||
| 598 | return ( | 811 | return ( |
| 599 | <Section title="OpenAPI Plugin Editor"> | 812 | <Section title="Edit Source"> |
| 600 | <div className="text-sm text-muted-foreground">Loading plugin config...</div> | 813 | <div className="text-sm text-muted-foreground">Loading configuration...</div> |
| 601 | </Section> | 814 | </Section> |
| 602 | ); | 815 | ); |
| 603 | } | 816 | } |
| @@ -692,6 +905,7 @@ function OpenApiSourceDetailPage(props: { · 88 unmodified lines | |||
| 692 | limit: 20, | 905 | limit: 20, |
| 693 | }); | 906 | }); |
| 694 | const toolDetail = useSourceToolDetail(props.source.id, selectedToolPath); | 907 | const toolDetail = useSourceToolDetail(props.source.id, selectedToolPath); |
| 908 | const [confirmDelete, setConfirmDelete] = useState(false); | ||
| 695 | 909 | ||
| 696 | const setRouteSearch = (next: { | 910 | const setRouteSearch = (next: { |
| 697 | tab?: "model" | "discover"; | 911 | tab?: "model" | "discover"; |
| @@ -781,23 +995,46 @@ function OpenApiSourceDetailPage(props: { · 83 unmodified lines | |||
| 781 | <IconPencil className="size-3" /> | 995 | <IconPencil className="size-3" /> |
| 782 | Edit | 996 | Edit |
| 783 | </button> | 997 | </button> |
| 784 | <button | 998 | {confirmDelete ? ( |
| 785 | type="button" | 999 | <div className="flex items-center gap-2"> |
| 786 | onClick={() => { | 1000 | <span className="text-[11px] font-medium text-destructive"> |
| 787 | const confirmed = window.confirm(`Delete OpenAPI source "${props.source.name}"?`); | 1001 | Confirm delete? |
| 788 | if (!confirmed) return; | 1002 | </span> |
| 789 | 1003 | <button | |
| 790 | void removeMutation.mutateAsync(props.source.id).then(() => { | 1004 | type="button" |
| 791 | startTransition(() => { | 1005 | onClick={() => setConfirmDelete(false)} |
| 792 | void navigation.home(); | 1006 | disabled={removeMutation.status === "pending"} |
| 793 | }); | 1007 | className="inline-flex items-center rounded-md border border-border bg-card px-2.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:pointer-events-none disabled:opacity-50" |
| 794 | }); | 1008 | > |
| 795 | }} | 1009 | Cancel |
| 796 | disabled={removeMutation.status === "pending"} | 1010 | </button> |
| 797 | className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" | 1011 | <button |
| 798 | > | 1012 | type="button" |
| 799 | {removeMutation.status === "pending" ? "Deleting..." : "Delete"} | 1013 | onClick={() => { |
| 800 | </button> | 1014 | void removeMutation.mutateAsync(props.source.id).then(() => { |
| 1015 | startTransition(() => { | ||
| 1016 | void navigation.home(); | ||
| 1017 | }); | ||
| 1018 | }).finally(() => { | ||
| 1019 | setConfirmDelete(false); | ||
| 1020 | }); | ||
| 1021 | }} | ||
| 1022 | disabled={removeMutation.status === "pending"} | ||
| 1023 | className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" | ||
| 1024 | > | ||
| 1025 | {removeMutation.status === "pending" ? "Deleting..." : "Delete"} | ||
| 1026 | </button> | ||
| 1027 | </div> | ||
| 1028 | ) : ( | ||
| 1029 | <button | ||
| 1030 | type="button" | ||
| 1031 | onClick={() => setConfirmDelete(true)} | ||
| 1032 | disabled={removeMutation.status === "pending"} | ||
| 1033 | className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" | ||
| 1034 | > | ||
| 1035 | Delete | ||
| 1036 | </button> | ||
| 1037 | )} | ||
| 801 | </div> | 1038 | </div> |
| 802 | </div> | 1039 | </div> |
| 803 | 1040 | ||
| @@ -1425,9 +1662,7 @@ const openApiSourceType = defineFrontendSourceType({ · 621 unmodified lines | |||
| 1425 | key: "openapi", | 1662 | key: "openapi", |
| 1426 | kind: "openapi", | 1663 | kind: "openapi", |
| 1427 | displayName: "OpenAPI", | 1664 | displayName: "OpenAPI", |
| 1428 | renderAddPage: () => ( | 1665 | renderAddPage: OpenApiAddSourcePage, |
| 1429 | <OpenApiAddSourcePage initialValue={defaultOpenApiInput()} /> | ||
| 1430 | ), | ||
| 1431 | renderEditPage: OpenApiEditSourcePage, | 1666 | renderEditPage: OpenApiEditSourcePage, |
| 1432 | renderDetailPage: OpenApiSourceDetailPage, | 1667 | renderDetailPage: OpenApiSourceDetailPage, |
| 1433 | }); | 1668 | }); |
| @@ -102,12 +102,49 @@ const OpenApiExecutorAddInputSchema = Schema.Struct({ · 101 unmodified lines | |||
| 102 | 102 | ||
| 103 | type OpenApiExecutorAddInput = typeof OpenApiExecutorAddInputSchema.Type; | 103 | type OpenApiExecutorAddInput = typeof OpenApiExecutorAddInputSchema.Type; |
| 104 | 104 | ||
| 105 | const normalizeOpenApiAuth = ( | ||
| 106 | auth: Record<string, unknown>, | ||
| 107 | ): OpenApiStoredSourceData["auth"] => | ||
| 108 | auth.kind === "bearer" | ||
| 109 | ? { | ||
| 110 | kind: "bearer", | ||
| 111 | tokenSecretRef: auth.tokenSecretRef as Extract< | ||
| 112 | OpenApiStoredSourceData["auth"], | ||
| 113 | { kind: "bearer" } | ||
| 114 | >["tokenSecretRef"], | ||
| 115 | headerName: | ||
| 116 | typeof auth.headerName === "string" || auth.headerName === null | ||
| 117 | ? auth.headerName | ||
| 118 | : null, | ||
| 119 | prefix: | ||
| 120 | typeof auth.prefix === "string" || auth.prefix === null | ||
| 121 | ? auth.prefix | ||
| 122 | : null, | ||
| 123 | } | ||
| 124 | : { | ||
| 125 | kind: "none", | ||
| 126 | }; | ||
| 127 | |||
| 128 | const normalizeStoredSourceData = ( | ||
| 129 | stored: OpenApiStoredSourceData, | ||
| 130 | ): OpenApiStoredSourceData => ({ | ||
| 131 | ...stored, | ||
| 132 | auth: normalizeOpenApiAuth(stored.auth as Record<string, unknown>), | ||
| 133 | }); | ||
| 134 | |||
| 105 | const createStoredSourceData = ( | 135 | const createStoredSourceData = ( |
| 106 | input: OpenApiConnectInput, | 136 | input: OpenApiConnectInput, |
| 107 | ): OpenApiStoredSourceData => ({ | 137 | ): OpenApiStoredSourceData => ({ |
| 108 | specUrl: input.specUrl.trim(), | 138 | specUrl: input.specUrl.trim(), |
| 109 | baseUrl: input.baseUrl?.trim() || null, | 139 | baseUrl: input.baseUrl?.trim() || null, |
| 110 | auth: input.auth, | 140 | auth: |
| 141 | input.auth.kind === "bearer" | ||
| 142 | ? { | ||
| 143 | ...input.auth, | ||
| 144 | headerName: input.auth.headerName ?? null, | ||
| 145 | prefix: input.auth.prefix ?? null, | ||
| 146 | } | ||
| 147 | : input.auth, | ||
| 111 | defaultHeaders: null, | 148 | defaultHeaders: null, |
| 112 | etag: null, | 149 | etag: null, |
| 113 | lastSyncAt: null, | 150 | lastSyncAt: null, |
| @@ -284,7 +321,10 @@ const createOpenApiSourceSdk = ( · 170 unmodified lines | |||
| 284 | ); | 321 | ); |
| 285 | } | 322 | } |
| 286 | 323 | ||
| 287 | return configFromStoredSourceData(source, stored); | 324 | return configFromStoredSourceData( |
| 325 | source, | ||
| 326 | normalizeStoredSourceData(stored), | ||
| 327 | ); | ||
| 288 | }), | 328 | }), |
| 289 | createSource: (input: OpenApiConnectInput) => | 329 | createSource: (input: OpenApiConnectInput) => |
| 290 | Effect.gen(function* () { | 330 | Effect.gen(function* () { |
| @@ -424,6 +464,17 @@ const resolveBearerToken = ( · 133 unmodified lines | |||
| 424 | ); | 464 | ); |
| 425 | }; | 465 | }; |
| 426 | 466 | ||
| 467 | const resolveBearerHeaderName = ( | ||
| 468 | auth: Extract<OpenApiStoredSourceData["auth"], { kind: "bearer" }>, | ||
| 469 | ): string => { | ||
| 470 | const trimmed = auth.headerName?.trim(); | ||
| 471 | return trimmed && trimmed.length > 0 ? trimmed : "Authorization"; | ||
| 472 | }; | ||
| 473 | |||
| 474 | const resolveBearerPrefix = ( | ||
| 475 | auth: Extract<OpenApiStoredSourceData["auth"], { kind: "bearer" }>, | ||
| 476 | ): string => auth.prefix ?? "Bearer "; | ||
| 477 | |||
| 427 | const openApiDocumentHeaders = (input: { | 478 | const openApiDocumentHeaders = (input: { |
| 428 | stored: OpenApiStoredSourceData; | 479 | stored: OpenApiStoredSourceData; |
| 429 | bearerToken: string | null; | 480 | bearerToken: string | null; |
| @@ -434,8 +485,15 @@ const openApiDocumentHeaders = (input: { · 4 unmodified lines | |||
| 434 | for (const [key, value] of Object.entries(input.stored.defaultHeaders ?? {})) { | 485 | for (const [key, value] of Object.entries(input.stored.defaultHeaders ?? {})) { |
| 435 | headers.set(key, value); | 486 | headers.set(key, value); |
| 436 | } | 487 | } |
| 437 | if (input.bearerToken && input.bearerToken.length > 0) { | 488 | if ( |
| 438 | headers.set("authorization", `Bearer ${input.bearerToken}`); | 489 | input.bearerToken && |
| 490 | input.bearerToken.length > 0 && | ||
| 491 | input.stored.auth.kind === "bearer" | ||
| 492 | ) { | ||
| 493 | headers.set( | ||
| 494 | resolveBearerHeaderName(input.stored.auth), | ||
| 495 | `${resolveBearerPrefix(input.stored.auth)}${input.bearerToken}`, | ||
| 496 | ); | ||
| 439 | } | 497 | } |
| 440 | if (input.etag) { | 498 | if (input.etag) { |
| 441 | headers.set("if-none-match", input.etag); | 499 | headers.set("if-none-match", input.etag); |
| @@ -542,17 +600,18 @@ const createOpenApiSourceRuntime = ( · 100 unmodified lines | |||
| 542 | }); | 600 | }); |
| 543 | } | 601 | } |
| 544 | 602 | ||
| 545 | const bearerToken = yield* resolveBearerToken(stored); | 603 | const normalizedStored = normalizeStoredSourceData(stored); |
| 604 | const bearerToken = yield* resolveBearerToken(normalizedStored); | ||
| 546 | const fetched = yield* fetchOpenApiDocument({ | 605 | const fetched = yield* fetchOpenApiDocument({ |
| 547 | stored, | 606 | stored: normalizedStored, |
| 548 | bearerToken, | 607 | bearerToken, |
| 549 | }); | 608 | }); |
| 550 | const manifest = yield* extractOpenApiManifest(source.name, fetched.text, { | 609 | const manifest = yield* extractOpenApiManifest(source.name, fetched.text, { |
| 551 | documentUrl: stored.specUrl, | 610 | documentUrl: normalizedStored.specUrl, |
| 552 | loadDocument: async (url) => { | 611 | loadDocument: async (url) => { |
| 553 | const response = await fetch(url, { | 612 | const response = await fetch(url, { |
| 554 | headers: openApiDocumentHeaders({ | 613 | headers: openApiDocumentHeaders({ |
| 555 | stored, | 614 | stored: normalizedStored, |
| 556 | bearerToken, | 615 | bearerToken, |
| 557 | }), | 616 | }), |
| 558 | }); | 617 | }); |
| @@ -571,7 +630,7 @@ const createOpenApiSourceRuntime = ( · 12 unmodified lines | |||
| 571 | scopeId: source.scopeId, | 630 | scopeId: source.scopeId, |
| 572 | sourceId: source.id, | 631 | sourceId: source.id, |
| 573 | value: { | 632 | value: { |
| 574 | ...stored, | 633 | ...normalizedStored, |
| 575 | etag: fetched.etag, | 634 | etag: fetched.etag, |
| 576 | lastSyncAt: now, | 635 | lastSyncAt: now, |
| 577 | }, | 636 | }, |
| @@ -583,7 +642,7 @@ const createOpenApiSourceRuntime = ( · 5 unmodified lines | |||
| 583 | documents: [ | 642 | documents: [ |
| 584 | { | 643 | { |
| 585 | documentKind: "openapi", | 644 | documentKind: "openapi", |
| 586 | documentKey: stored.specUrl, | 645 | documentKey: normalizedStored.specUrl, |
| 587 | contentText: fetched.text, | 646 | contentText: fetched.text, |
| 588 | fetchedAt: now, | 647 | fetchedAt: now, |
| 589 | }, | 648 | }, |
| @@ -596,7 +655,7 @@ const createOpenApiSourceRuntime = ( · 6 unmodified lines | |||
| 596 | pluginKey: "openapi", | 655 | pluginKey: "openapi", |
| 597 | }), | 656 | }), |
| 598 | importerVersion: "ir.v1.openapi", | 657 | importerVersion: "ir.v1.openapi", |
| 599 | sourceConfigHash: stableSourceHash(stored), | 658 | sourceConfigHash: stableSourceHash(normalizedStored), |
| 600 | }, | 659 | }, |
| 601 | sourceHash: manifest.sourceHash, | 660 | sourceHash: manifest.sourceHash, |
| 602 | }); | 661 | }); |
| @@ -613,6 +672,7 @@ const createOpenApiSourceRuntime = ( · 10 unmodified lines | |||
| 613 | ); | 672 | ); |
| 614 | } | 673 | } |
| 615 | 674 | ||
| 675 | const normalizedStored = normalizeStoredSourceData(stored); | ||
| 616 | const providerData = decodeProviderData( | 676 | const providerData = decodeProviderData( |
| 617 | input.executable.binding, | 677 | input.executable.binding, |
| 618 | ) as OpenApiToolProviderData; | 678 | ) as OpenApiToolProviderData; |
| @@ -623,7 +683,7 @@ const createOpenApiSourceRuntime = ( · 4 unmodified lines | |||
| 623 | providerData.invocation, | 683 | providerData.invocation, |
| 624 | ); | 684 | ); |
| 625 | const headers: Record<string, string> = { | 685 | const headers: Record<string, string> = { |
| 626 | ...(stored.defaultHeaders ?? {}), | 686 | ...(normalizedStored.defaultHeaders ?? {}), |
| 627 | }; | 687 | }; |
| 628 | const queryEntries: Array<{ | 688 | const queryEntries: Array<{ |
| 629 | name: string; | 689 | name: string; |
| @@ -678,14 +738,19 @@ const createOpenApiSourceRuntime = ( · 48 unmodified lines | |||
| 678 | } | 738 | } |
| 679 | } | 739 | } |
| 680 | 740 | ||
| 681 | const bearerToken = yield* resolveBearerToken(stored); | 741 | const bearerToken = yield* resolveBearerToken(normalizedStored); |
| 682 | if (bearerToken && bearerToken.length > 0) { | 742 | if ( |
| 683 | headers.authorization = `Bearer ${bearerToken}`; | 743 | bearerToken && |
| 744 | bearerToken.length > 0 && | ||
| 745 | normalizedStored.auth.kind === "bearer" | ||
| 746 | ) { | ||
| 747 | headers[resolveBearerHeaderName(normalizedStored.auth)] = | ||
| 748 | `${resolveBearerPrefix(normalizedStored.auth)}${bearerToken}`; | ||
| 684 | } | 749 | } |
| 685 | 750 | ||
| 686 | const requestUrl = resolveRequestUrl( | 751 | const requestUrl = resolveRequestUrl( |
| 687 | resolveOpenApiBaseUrl({ | 752 | resolveOpenApiBaseUrl({ |
| 688 | stored, | 753 | stored: normalizedStored, |
| 689 | providerData, | 754 | providerData, |
| 690 | }), | 755 | }), |
| 691 | resolvedPath, | 756 | resolvedPath, |
| @@ -14,6 +14,8 @@ export const OpenApiConnectionAuthSchema = Schema.Union( · 13 unmodified lines | |||
| 14 | Schema.Struct({ | 14 | Schema.Struct({ |
| 15 | kind: Schema.Literal("bearer"), | 15 | kind: Schema.Literal("bearer"), |
| 16 | tokenSecretRef: SecretRefSchema, | 16 | tokenSecretRef: SecretRefSchema, |
| 17 | headerName: Schema.NullOr(Schema.String), | ||
| 18 | prefix: Schema.NullOr(Schema.String), | ||
| 17 | }), | 19 | }), |
| 18 | ); | 20 | ); |
| 19 | 21 | ||