Unified · Split
| @@ -1,7 +1,7 @@ | ||
| 1 | −import { useReducer, useCallback, Suspense } from "react"; | |
| 1 | +import { useReducer, useCallback, useMemo, Suspense } from "react"; | |
| 2 | 2 | import { Link } from "@tanstack/react-router"; |
| 3 | 3 | import { Result, useAtomValue, useAtomRefresh, useAtomSet, sourcesAtom, detectSource } from "@executor/react"; |
| 4 | −import type { SourcePlugin } from "@executor/react"; | |
| 4 | +import type { SourcePlugin, SourcePreset } from "@executor/react"; | |
| 5 | 5 | import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; |
| 6 | 6 | import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; |
| 7 | 7 | import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react"; |
| @@ -46,6 +46,7 @@ type Action = · 38 unmodified lines | ||
| 46 | 46 | | { type: "detect-unknown-kind"; kind: string } |
| 47 | 47 | | { type: "detect-fail" } |
| 48 | 48 | | { type: "add-manual"; pluginKey: string } |
| 49 | + | { type: "add-preset"; pluginKey: string; url: string } | |
| 49 | 50 | | { type: "back" }; |
| 50 | 51 | |
| 51 | 52 | const init: State = { step: "list", url: "", error: null, pluginKey: null, initialUrl: undefined }; |
| @@ -66,6 +67,8 @@ function reducer(state: State, action: Action): State { · 14 unmodified lines | ||
| 66 | 67 | return { ...state, step: "list", error: "Detection failed. Try adding a source manually." }; |
| 67 | 68 | case "add-manual": |
| 68 | 69 | return { ...state, step: "adding", pluginKey: action.pluginKey, initialUrl: undefined }; |
| 70 | + case "add-preset": | |
| 71 | + return { ...state, step: "adding", pluginKey: action.pluginKey, initialUrl: action.url }; | |
| 69 | 72 | case "back": |
| 70 | 73 | return init; |
| 71 | 74 | default: |
| @@ -196,6 +199,8 @@ export function SourcesPage() { · 124 unmodified lines | ||
| 196 | 199 | |
| 197 | 200 | <McpInstallCard className="mb-8 rounded-2xl border border-border bg-card/80 p-5" /> |
| 198 | 201 | |
| 202 | + <PresetGrid plugins={sourcePlugins} dispatch={dispatch} /> | |
| 203 | + | |
| 199 | 204 | {Result.match(sources, { |
| 200 | 205 | onInitial: () => ( |
| 201 | 206 | <p className="text-sm text-muted-foreground">Loading…</p> |
| @@ -259,6 +264,66 @@ export function SourcesPage() { · 57 unmodified lines | ||
| 259 | 264 | ); |
| 260 | 265 | } |
| 261 | 266 | |
| 267 | +// --------------------------------------------------------------------------- | |
| 268 | +// Preset grid | |
| 269 | +// --------------------------------------------------------------------------- | |
| 270 | + | |
| 271 | +function PresetGrid(props: { | |
| 272 | + plugins: readonly SourcePlugin[]; | |
| 273 | + dispatch: React.Dispatch<Action>; | |
| 274 | +}) { | |
| 275 | + const allPresets = useMemo(() => { | |
| 276 | + const out: { preset: SourcePreset; pluginKey: string; pluginLabel: string }[] = []; | |
| 277 | + for (const plugin of props.plugins) { | |
| 278 | + for (const preset of plugin.presets ?? []) { | |
| 279 | + out.push({ preset, pluginKey: plugin.key, pluginLabel: plugin.label }); | |
| 280 | + } | |
| 281 | + } | |
| 282 | + return out; | |
| 283 | + }, [props.plugins]); | |
| 284 | + | |
| 285 | + if (allPresets.length === 0) return null; | |
| 286 | + | |
| 287 | + return ( | |
| 288 | + <section className="mb-8 space-y-3"> | |
| 289 | + <div> | |
| 290 | + <h2 className="text-sm font-semibold text-foreground">Presets</h2> | |
| 291 | + <p className="mt-1 text-[13px] text-muted-foreground"> | |
| 292 | + One-click setup for common APIs and services. | |
| 293 | + </p> | |
| 294 | + </div> | |
| 295 | + <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 296 | + {allPresets.map(({ preset, pluginKey, pluginLabel }) => ( | |
| 297 | + <button | |
| 298 | + key={`${pluginKey}-${preset.id}`} | |
| 299 | + onClick={() => props.dispatch({ type: "add-preset", pluginKey, url: preset.url })} | |
| 300 | + className="flex items-start gap-3 rounded-xl border border-border bg-card px-4 py-3 text-left transition-colors hover:border-primary/25 hover:bg-card/90" | |
| 301 | + > | |
| 302 | + <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground overflow-hidden"> | |
| 303 | + {preset.icon ? ( | |
| 304 | + <img src={preset.icon} alt="" className="size-5 object-contain" loading="lazy" /> | |
| 305 | + ) : ( | |
| 306 | + <svg viewBox="0 0 16 16" className="size-3.5" fill="none"> | |
| 307 | + <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" /> | |
| 308 | + </svg> | |
| 309 | + )} | |
| 310 | + </div> | |
| 311 | + <div className="min-w-0 flex-1"> | |
| 312 | + <div className="flex items-center gap-2"> | |
| 313 | + <span className="truncate text-sm font-medium text-foreground">{preset.name}</span> | |
| 314 | + <span className="shrink-0 rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground"> | |
| 315 | + {pluginLabel} | |
| 316 | + </span> | |
| 317 | + </div> | |
| 318 | + <p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">{preset.summary}</p> | |
| 319 | + </div> | |
| 320 | + </button> | |
| 321 | + ))} | |
| 322 | + </div> | |
| 323 | + </section> | |
| 324 | + ); | |
| 325 | +} | |
| 326 | + | |
| 262 | 327 | // --------------------------------------------------------------------------- |
| 263 | 328 | // Source grid |
| 264 | 329 | // --------------------------------------------------------------------------- |
| @@ -6,9 +6,13 @@ · 5 unmodified lines | ||
| 6 | 6 | "name": "v4-executor", |
| 7 | 7 | "devDependencies": { |
| 8 | 8 | "@effect/language-service": "^0.84.2", |
| 9 | + "@effect/platform": "catalog:", | |
| 10 | + "@effect/vitest": "catalog:", | |
| 11 | + "effect": "catalog:", | |
| 9 | 12 | "oxlint": "^1.56.0", |
| 10 | 13 | "turbo": "^2.5.6", |
| 11 | 14 | "typescript": "^5.9.3", |
| 15 | + "vitest": "catalog:", | |
| 12 | 16 | }, |
| 13 | 17 | }, |
| 14 | 18 | "apps/cli": { |
| @@ -54,9 +54,13 @@ · 53 unmodified lines | ||
| 54 | 54 | }, |
| 55 | 55 | "devDependencies": { |
| 56 | 56 | "@effect/language-service": "^0.84.2", |
| 57 | + "@effect/platform": "catalog:", | |
| 58 | + "@effect/vitest": "catalog:", | |
| 59 | + "effect": "catalog:", | |
| 57 | 60 | "oxlint": "^1.56.0", |
| 58 | 61 | "turbo": "^2.5.6", |
| 59 | − "typescript": "^5.9.3" | |
| 62 | + "typescript": "^5.9.3", | |
| 63 | + "vitest": "catalog:" | |
| 60 | 64 | }, |
| 61 | 65 | "dependencies": {} |
| 62 | 66 | } |
| @@ -46,5 +46,5 @@ export { ScopeId, ToolId } from "@executor/sdk"; · 45 unmodified lines | ||
| 46 | 46 | export type { ScopeId as ScopeIdType, ToolId as ToolIdType } from "@executor/sdk"; |
| 47 | 47 | |
| 48 | 48 | // Plugin contracts |
| 49 | −export type { SourcePlugin } from "./source-plugin"; | |
| 49 | +export type { SourcePlugin, SourcePreset } from "./source-plugin"; | |
| 50 | 50 | export type { SecretProviderPlugin } from "./secret-provider-plugin"; |
| @@ -1,5 +1,22 @@ | ||
| 1 | 1 | import type { ComponentType } from "react"; |
| 2 | 2 | |
| 3 | +/** | |
| 4 | + * A curated preset — a well-known API/service that can be added with one click. | |
| 5 | + * Each plugin provides its own presets. | |
| 6 | + */ | |
| 7 | +export interface SourcePreset { | |
| 8 | + /** Unique id (e.g. "stripe", "github-graphql") */ | |
| 9 | + readonly id: string; | |
| 10 | + /** Display name */ | |
| 11 | + readonly name: string; | |
| 12 | + /** One-line description */ | |
| 13 | + readonly summary: string; | |
| 14 | + /** URL passed as `initialUrl` to the add form */ | |
| 15 | + readonly url: string; | |
| 16 | + /** Optional icon URL (favicon, logo) */ | |
| 17 | + readonly icon?: string; | |
| 18 | +} | |
| 19 | + | |
| 3 | 20 | /** |
| 4 | 21 | * Contract between the shell and source plugins. |
| 5 | 22 | * |
| @@ -48,4 +65,7 @@ export interface SourcePlugin { · 42 unmodified lines | ||
| 48 | 65 | readonly summary?: ComponentType<{ |
| 49 | 66 | readonly sourceId: string; |
| 50 | 67 | }>; |
| 68 | + | |
| 69 | + /** Curated presets shown on the sources page for quick-add */ | |
| 70 | + readonly presets?: readonly SourcePreset[]; | |
| 51 | 71 | } |
| @@ -6,7 +6,8 @@ · 5 unmodified lines | ||
| 6 | 6 | "exports": { |
| 7 | 7 | ".": "./src/sdk/index.ts", |
| 8 | 8 | "./api": "./src/api/index.ts", |
| 9 | − "./react": "./src/react/index.ts" | |
| 9 | + "./react": "./src/react/index.ts", | |
| 10 | + "./presets": "./src/sdk/presets.ts" | |
| 10 | 11 | }, |
| 11 | 12 | "scripts": { |
| 12 | 13 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { lazy } from "react"; |
| 2 | 2 | import type { SourcePlugin } from "@executor/react"; |
| 3 | +import { googleDiscoveryPresets } from "../sdk/presets"; | |
| 3 | 4 | |
| 4 | 5 | export const googleDiscoverySourcePlugin: SourcePlugin = { |
| 5 | 6 | key: "googleDiscovery", |
| @@ -7,4 +8,5 @@ export const googleDiscoverySourcePlugin: SourcePlugin = { · 1 unmodified line | ||
| 7 | 8 | add: lazy(() => import("./AddGoogleDiscoverySource")), |
| 8 | 9 | edit: lazy(() => import("./EditGoogleDiscoverySource")), |
| 9 | 10 | summary: lazy(() => import("./GoogleDiscoverySourceSummary")), |
| 11 | + presets: googleDiscoveryPresets, | |
| 10 | 12 | }; |
| @@ -0,0 +1,48 @@ | ||
| 1 | +export interface GoogleDiscoveryPreset { | |
| 2 | + readonly id: string; | |
| 3 | + readonly name: string; | |
| 4 | + readonly summary: string; | |
| 5 | + readonly url: string; | |
| 6 | + readonly icon?: string; | |
| 7 | +} | |
| 8 | + | |
| 9 | +const gd = (service: string, version: string) => | |
| 10 | + `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`; | |
| 11 | + | |
| 12 | +export const googleDiscoveryPresets: readonly GoogleDiscoveryPreset[] = [ | |
| 13 | + { | |
| 14 | + id: "google-calendar", | |
| 15 | + name: "Google Calendar", | |
| 16 | + summary: "Calendars, events, ACLs, and scheduling.", | |
| 17 | + url: gd("calendar", "v3"), | |
| 18 | + icon: "https://fonts.gstatic.com/s/i/productlogos/calendar_2020q4/v8/192px.svg", | |
| 19 | + }, | |
| 20 | + { | |
| 21 | + id: "google-gmail", | |
| 22 | + name: "Gmail", | |
| 23 | + summary: "Messages, threads, labels, and drafts.", | |
| 24 | + url: gd("gmail", "v1"), | |
| 25 | + icon: "https://fonts.gstatic.com/s/i/productlogos/gmail_2020q4/v8/web-96dp/logo_gmail_2020q4_color_2x_web_96dp.png", | |
| 26 | + }, | |
| 27 | + { | |
| 28 | + id: "google-sheets", | |
| 29 | + name: "Google Sheets", | |
| 30 | + summary: "Spreadsheets, values, ranges, and formatting.", | |
| 31 | + url: gd("sheets", "v4"), | |
| 32 | + icon: "https://fonts.gstatic.com/s/i/productlogos/sheets_2020q4/v8/192px.svg", | |
| 33 | + }, | |
| 34 | + { | |
| 35 | + id: "google-drive", | |
| 36 | + name: "Google Drive", | |
| 37 | + summary: "Files, folders, permissions, and shared drives.", | |
| 38 | + url: gd("drive", "v3"), | |
| 39 | + icon: "https://fonts.gstatic.com/s/i/productlogos/drive_2020q4/v8/192px.svg", | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + id: "google-docs", | |
| 43 | + name: "Google Docs", | |
| 44 | + summary: "Documents, structural edits, and formatting.", | |
| 45 | + url: gd("docs", "v1"), | |
| 46 | + icon: "https://fonts.gstatic.com/s/i/productlogos/docs_2020q4/v12/192px.svg", | |
| 47 | + }, | |
| 48 | +]; | |
| @@ -6,7 +6,8 @@ · 5 unmodified lines | ||
| 6 | 6 | "exports": { |
| 7 | 7 | ".": "./src/sdk/index.ts", |
| 8 | 8 | "./api": "./src/api/index.ts", |
| 9 | − "./react": "./src/react/index.ts" | |
| 9 | + "./react": "./src/react/index.ts", | |
| 10 | + "./presets": "./src/sdk/presets.ts" | |
| 10 | 11 | }, |
| 11 | 12 | "scripts": { |
| 12 | 13 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", |
| @@ -1,9 +1,6 @@ | ||
| 1 | 1 | import { lazy } from "react"; |
| 2 | 2 | import type { SourcePlugin } from "@executor/react"; |
| 3 | − | |
| 4 | −// --------------------------------------------------------------------------- | |
| 5 | −// GraphQL source plugin — lazy-loaded components | |
| 6 | −// --------------------------------------------------------------------------- | |
| 3 | +import { graphqlPresets } from "../sdk/presets"; | |
| 7 | 4 | |
| 8 | 5 | export const graphqlSourcePlugin: SourcePlugin = { |
| 9 | 6 | key: "graphql", |
| @@ -11,4 +8,5 @@ export const graphqlSourcePlugin: SourcePlugin = { · 1 unmodified line | ||
| 11 | 8 | add: lazy(() => import("./AddGraphqlSource")), |
| 12 | 9 | edit: lazy(() => import("./EditGraphqlSource")), |
| 13 | 10 | summary: lazy(() => import("./GraphqlSourceSummary")), |
| 11 | + presets: graphqlPresets, | |
| 14 | 12 | }; |
| @@ -0,0 +1,45 @@ | ||
| 1 | +export interface GraphqlPreset { | |
| 2 | + readonly id: string; | |
| 3 | + readonly name: string; | |
| 4 | + readonly summary: string; | |
| 5 | + readonly url: string; | |
| 6 | + readonly icon?: string; | |
| 7 | +} | |
| 8 | + | |
| 9 | +export const graphqlPresets: readonly GraphqlPreset[] = [ | |
| 10 | + { | |
| 11 | + id: "github-graphql", | |
| 12 | + name: "GitHub GraphQL", | |
| 13 | + summary: "Repos, issues, PRs, and users via GitHub's GraphQL API.", | |
| 14 | + url: "https://api.github.com/graphql", | |
| 15 | + icon: "https://github.com/favicon.ico", | |
| 16 | + }, | |
| 17 | + { | |
| 18 | + id: "gitlab", | |
| 19 | + name: "GitLab", | |
| 20 | + summary: "Projects, merge requests, pipelines, and users.", | |
| 21 | + url: "https://gitlab.com/api/graphql", | |
| 22 | + icon: "https://gitlab.com/favicon.ico", | |
| 23 | + }, | |
| 24 | + { | |
| 25 | + id: "linear", | |
| 26 | + name: "Linear", | |
| 27 | + summary: "Issues, projects, teams, and cycles.", | |
| 28 | + url: "https://api.linear.app/graphql", | |
| 29 | + icon: "https://linear.app/favicon.ico", | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + id: "monday", | |
| 33 | + name: "Monday.com", | |
| 34 | + summary: "Boards, items, columns, and workspace automation.", | |
| 35 | + url: "https://api.monday.com/v2", | |
| 36 | + icon: "https://monday.com/favicon.ico", | |
| 37 | + }, | |
| 38 | + { | |
| 39 | + id: "anilist", | |
| 40 | + name: "AniList", | |
| 41 | + summary: "Anime and manga database — no auth required.", | |
| 42 | + url: "https://graphql.anilist.co", | |
| 43 | + icon: "https://anilist.co/img/icons/favicon-32x32.png", | |
| 44 | + }, | |
| 45 | +]; | |
| @@ -6,7 +6,8 @@ · 5 unmodified lines | ||
| 6 | 6 | "exports": { |
| 7 | 7 | ".": "./src/sdk/index.ts", |
| 8 | 8 | "./api": "./src/api/index.ts", |
| 9 | − "./react": "./src/react/index.ts" | |
| 9 | + "./react": "./src/react/index.ts", | |
| 10 | + "./presets": "./src/sdk/presets.ts" | |
| 10 | 11 | }, |
| 11 | 12 | "scripts": { |
| 12 | 13 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", |
| @@ -1,9 +1,6 @@ | ||
| 1 | 1 | import { lazy } from "react"; |
| 2 | 2 | import type { SourcePlugin } from "@executor/react"; |
| 3 | − | |
| 4 | −// --------------------------------------------------------------------------- | |
| 5 | −// MCP source plugin — lazy-loaded components | |
| 6 | −// --------------------------------------------------------------------------- | |
| 3 | +import { mcpPresets } from "../sdk/presets"; | |
| 7 | 4 | |
| 8 | 5 | export const mcpSourcePlugin: SourcePlugin = { |
| 9 | 6 | key: "mcp", |
| @@ -11,4 +8,5 @@ export const mcpSourcePlugin: SourcePlugin = { · 1 unmodified line | ||
| 11 | 8 | add: lazy(() => import("./AddMcpSource")), |
| 12 | 9 | edit: lazy(() => import("./EditMcpSource")), |
| 13 | 10 | summary: lazy(() => import("./McpSourceSummary")), |
| 11 | + presets: mcpPresets, | |
| 14 | 12 | }; |
| @@ -0,0 +1,45 @@ | ||
| 1 | +export interface McpPreset { | |
| 2 | + readonly id: string; | |
| 3 | + readonly name: string; | |
| 4 | + readonly summary: string; | |
| 5 | + readonly url: string; | |
| 6 | + readonly icon?: string; | |
| 7 | +} | |
| 8 | + | |
| 9 | +export const mcpPresets: readonly McpPreset[] = [ | |
| 10 | + { | |
| 11 | + id: "deepwiki", | |
| 12 | + name: "DeepWiki", | |
| 13 | + summary: "Search and read documentation from any GitHub repo.", | |
| 14 | + url: "https://mcp.deepwiki.com/mcp", | |
| 15 | + icon: "https://deepwiki.com/favicon.ico", | |
| 16 | + }, | |
| 17 | + { | |
| 18 | + id: "context7", | |
| 19 | + name: "Context7", | |
| 20 | + summary: "Up-to-date docs and code examples for any library.", | |
| 21 | + url: "https://mcp.context7.com/mcp", | |
| 22 | + icon: "https://context7.com/favicon.ico", | |
| 23 | + }, | |
| 24 | + { | |
| 25 | + id: "browserbase", | |
| 26 | + name: "Browserbase", | |
| 27 | + summary: "Cloud browser sessions for web scraping and automation.", | |
| 28 | + url: "https://mcp.browserbase.com/mcp", | |
| 29 | + icon: "https://www.browserbase.com/favicon.ico", | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + id: "firecrawl", | |
| 33 | + name: "Firecrawl", | |
| 34 | + summary: "Crawl and scrape websites into structured data.", | |
| 35 | + url: "https://mcp.firecrawl.dev/mcp", | |
| 36 | + icon: "https://www.firecrawl.dev/favicon.ico", | |
| 37 | + }, | |
| 38 | + { | |
| 39 | + id: "neon", | |
| 40 | + name: "Neon", | |
| 41 | + summary: "Serverless Postgres — branches, queries, and management.", | |
| 42 | + url: "https://mcp.neon.tech/mcp", | |
| 43 | + icon: "https://neon.tech/favicon/favicon.ico", | |
| 44 | + }, | |
| 45 | +]; | |
| @@ -6,7 +6,8 @@ · 5 unmodified lines | ||
| 6 | 6 | "exports": { |
| 7 | 7 | ".": "./src/sdk/index.ts", |
| 8 | 8 | "./api": "./src/api/index.ts", |
| 9 | − "./react": "./src/react/index.ts" | |
| 9 | + "./react": "./src/react/index.ts", | |
| 10 | + "./presets": "./src/sdk/presets.ts" | |
| 10 | 11 | }, |
| 11 | 12 | "scripts": { |
| 12 | 13 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", |
| @@ -1,9 +1,6 @@ | ||
| 1 | 1 | import { lazy } from "react"; |
| 2 | 2 | import type { SourcePlugin } from "@executor/react"; |
| 3 | − | |
| 4 | −// --------------------------------------------------------------------------- | |
| 5 | −// OpenAPI source plugin — lazy-loaded components | |
| 6 | −// --------------------------------------------------------------------------- | |
| 3 | +import { openApiPresets } from "../sdk/presets"; | |
| 7 | 4 | |
| 8 | 5 | export const openApiSourcePlugin: SourcePlugin = { |
| 9 | 6 | key: "openapi", |
| @@ -11,4 +8,5 @@ export const openApiSourcePlugin: SourcePlugin = { · 1 unmodified line | ||
| 11 | 8 | add: lazy(() => import("./AddOpenApiSource")), |
| 12 | 9 | edit: lazy(() => import("./EditOpenApiSource")), |
| 13 | 10 | summary: lazy(() => import("./OpenApiSourceSummary")), |
| 11 | + presets: openApiPresets, | |
| 14 | 12 | }; |
| @@ -0,0 +1,66 @@ | ||
| 1 | +export interface OpenApiPreset { | |
| 2 | + readonly id: string; | |
| 3 | + readonly name: string; | |
| 4 | + readonly summary: string; | |
| 5 | + readonly url: string; | |
| 6 | + readonly icon?: string; | |
| 7 | +} | |
| 8 | + | |
| 9 | +export const openApiPresets: readonly OpenApiPreset[] = [ | |
| 10 | + { | |
| 11 | + id: "stripe", | |
| 12 | + name: "Stripe", | |
| 13 | + summary: "Payments, subscriptions, customers, and invoices.", | |
| 14 | + url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", | |
| 15 | + icon: "https://stripe.com/favicon.ico", | |
| 16 | + }, | |
| 17 | + { | |
| 18 | + id: "github-rest", | |
| 19 | + name: "GitHub REST", | |
| 20 | + summary: "Repos, issues, pull requests, actions, and users.", | |
| 21 | + url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", | |
| 22 | + icon: "https://github.com/favicon.ico", | |
| 23 | + }, | |
| 24 | + { | |
| 25 | + id: "asana", | |
| 26 | + name: "Asana", | |
| 27 | + summary: "Tasks, projects, teams, and workspace management.", | |
| 28 | + url: "https://raw.githubusercontent.com/APIs-guru/openapi-directory/main/APIs/asana.com/1.0/openapi.yaml", | |
| 29 | + icon: "https://asana.com/favicon.ico", | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + id: "cloudflare", | |
| 33 | + name: "Cloudflare", | |
| 34 | + summary: "DNS, workers, pages, R2, and security rules.", | |
| 35 | + url: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json", | |
| 36 | + icon: "https://cloudflare.com/favicon.ico", | |
| 37 | + }, | |
| 38 | + { | |
| 39 | + id: "vercel", | |
| 40 | + name: "Vercel", | |
| 41 | + summary: "Deployments, domains, projects, and edge config.", | |
| 42 | + url: "https://openapi.vercel.sh", | |
| 43 | + icon: "https://vercel.com/favicon.ico", | |
| 44 | + }, | |
| 45 | + { | |
| 46 | + id: "twilio", | |
| 47 | + name: "Twilio", | |
| 48 | + summary: "SMS, voice, video, and messaging APIs.", | |
| 49 | + url: "https://raw.githubusercontent.com/twilio/twilio-oai/main/spec/json/twilio_api_v2010.json", | |
| 50 | + icon: "https://twilio.com/favicon.ico", | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + id: "neon", | |
| 54 | + name: "Neon", | |
| 55 | + summary: "Serverless Postgres — projects, branches, and endpoints.", | |
| 56 | + url: "https://neon.tech/api_spec/release/v2.json", | |
| 57 | + icon: "https://neon.tech/favicon/favicon.ico", | |
| 58 | + }, | |
| 59 | + { | |
| 60 | + id: "digitalocean", | |
| 61 | + name: "DigitalOcean", | |
| 62 | + summary: "Droplets, Kubernetes, databases, and networking.", | |
| 63 | + url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml", | |
| 64 | + icon: "https://www.digitalocean.com/_next/static/media/favicon.594d6067.ico", | |
| 65 | + }, | |
| 66 | +]; | |
| @@ -0,0 +1,225 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect } from "effect"; | |
| 3 | +import { FetchHttpClient } from "@effect/platform"; | |
| 4 | + | |
| 5 | +import { createExecutor, makeTestConfig } from "../packages/core/sdk/src/index"; | |
| 6 | +import { openApiPlugin } from "../packages/plugins/openapi/src/sdk/plugin"; | |
| 7 | +import { parse } from "../packages/plugins/openapi/src/sdk/parse"; | |
| 8 | +import { mcpPlugin } from "../packages/plugins/mcp/src/sdk/plugin"; | |
| 9 | +import { graphqlPlugin } from "../packages/plugins/graphql/src/sdk/plugin"; | |
| 10 | +import { introspect } from "../packages/plugins/graphql/src/sdk/introspect"; | |
| 11 | +import { googleDiscoveryPlugin } from "../packages/plugins/google-discovery/src/sdk/plugin"; | |
| 12 | +import { extractGoogleDiscoveryManifest } from "../packages/plugins/google-discovery/src/sdk/document"; | |
| 13 | + | |
| 14 | +import { openApiPresets } from "../packages/plugins/openapi/src/sdk/presets"; | |
| 15 | +import { mcpPresets } from "../packages/plugins/mcp/src/sdk/presets"; | |
| 16 | +import { graphqlPresets } from "../packages/plugins/graphql/src/sdk/presets"; | |
| 17 | +import { googleDiscoveryPresets } from "../packages/plugins/google-discovery/src/sdk/presets"; | |
| 18 | + | |
| 19 | +// --------------------------------------------------------------------------- | |
| 20 | +// All presets with plugin metadata | |
| 21 | +// --------------------------------------------------------------------------- | |
| 22 | + | |
| 23 | +const allPresets = [ | |
| 24 | + ...openApiPresets.map((p) => ({ ...p, plugin: "openapi" as const })), | |
| 25 | + ...mcpPresets.map((p) => ({ ...p, plugin: "mcp" as const })), | |
| 26 | + ...graphqlPresets.map((p) => ({ ...p, plugin: "graphql" as const })), | |
| 27 | + ...googleDiscoveryPresets.map((p) => ({ ...p, plugin: "google-discovery" as const })), | |
| 28 | +]; | |
| 29 | + | |
| 30 | +// --------------------------------------------------------------------------- | |
| 31 | +// OpenAPI presets — parse the spec through the SDK | |
| 32 | +// --------------------------------------------------------------------------- | |
| 33 | + | |
| 34 | +describe("openapi presets parse as valid specs", () => { | |
| 35 | + for (const preset of openApiPresets) { | |
| 36 | + it.effect( | |
| 37 | + preset.name, | |
| 38 | + () => | |
| 39 | + Effect.gen(function* () { | |
| 40 | + const doc = yield* parse(preset.url); | |
| 41 | + expect(doc).toBeDefined(); | |
| 42 | + expect(doc.openapi).toBeDefined(); | |
| 43 | + }), | |
| 44 | + { timeout: 30_000 }, | |
| 45 | + ); | |
| 46 | + } | |
| 47 | +}); | |
| 48 | + | |
| 49 | +// --------------------------------------------------------------------------- | |
| 50 | +// GraphQL presets — introspect the endpoint (auth-required = 401 is ok) | |
| 51 | +// --------------------------------------------------------------------------- | |
| 52 | + | |
| 53 | +describe("graphql presets are reachable endpoints", () => { | |
| 54 | + for (const preset of graphqlPresets) { | |
| 55 | + it.effect( | |
| 56 | + preset.name, | |
| 57 | + () => | |
| 58 | + Effect.gen(function* () { | |
| 59 | + const result = yield* introspect(preset.url).pipe( | |
| 60 | + Effect.provide(FetchHttpClient.layer), | |
| 61 | + Effect.map((r) => ({ ok: true as const, schema: r })), | |
| 62 | + Effect.catchAll((err) => | |
| 63 | + Effect.succeed({ | |
| 64 | + ok: false as const, | |
| 65 | + message: String(err), | |
| 66 | + }), | |
| 67 | + ), | |
| 68 | + ); | |
| 69 | + | |
| 70 | + if (result.ok) { | |
| 71 | + // Public endpoint — introspection succeeded | |
| 72 | + expect(result.schema.__schema).toBeDefined(); | |
| 73 | + expect(result.schema.__schema.types.length).toBeGreaterThan(0); | |
| 74 | + } else { | |
| 75 | + // Auth-required — should fail with 401/403, not 404/timeout | |
| 76 | + expect( | |
| 77 | + result.message, | |
| 78 | + `${preset.name} should fail with auth error, not: ${result.message}`, | |
| 79 | + ).toMatch(/401|403|Unauthorized|Forbidden|auth/i); | |
| 80 | + } | |
| 81 | + }), | |
| 82 | + { timeout: 15_000 }, | |
| 83 | + ); | |
| 84 | + } | |
| 85 | +}); | |
| 86 | + | |
| 87 | +// --------------------------------------------------------------------------- | |
| 88 | +// MCP presets — probe the endpoint (POST to verify it's alive) | |
| 89 | +// --------------------------------------------------------------------------- | |
| 90 | + | |
| 91 | +describe("mcp presets are reachable endpoints", () => { | |
| 92 | + for (const preset of mcpPresets) { | |
| 93 | + it.effect( | |
| 94 | + preset.name, | |
| 95 | + () => | |
| 96 | + Effect.gen(function* () { | |
| 97 | + // Simple POST probe — MCP endpoints reject malformed requests | |
| 98 | + // but return non-404 status codes proving the service is up | |
| 99 | + const response = yield* Effect.tryPromise(() => | |
| 100 | + fetch(preset.url, { | |
| 101 | + method: "POST", | |
| 102 | + signal: AbortSignal.timeout(10_000), | |
| 103 | + headers: { "Content-Type": "application/json" }, | |
| 104 | + body: "{}", | |
| 105 | + redirect: "follow", | |
| 106 | + }), | |
| 107 | + ); | |
| 108 | + | |
| 109 | + expect( | |
| 110 | + response.status !== 404 && response.status !== 502 && response.status !== 503, | |
| 111 | + `${preset.name} returned ${response.status} — endpoint appears down`, | |
| 112 | + ).toBe(true); | |
| 113 | + }), | |
| 114 | + { timeout: 15_000 }, | |
| 115 | + ); | |
| 116 | + } | |
| 117 | +}); | |
| 118 | + | |
| 119 | +// --------------------------------------------------------------------------- | |
| 120 | +// Google Discovery presets — parse through the SDK manifest extractor | |
| 121 | +// --------------------------------------------------------------------------- | |
| 122 | + | |
| 123 | +describe("google discovery presets parse as valid manifests", () => { | |
| 124 | + for (const preset of googleDiscoveryPresets) { | |
| 125 | + it.effect( | |
| 126 | + preset.name, | |
| 127 | + () => | |
| 128 | + Effect.gen(function* () { | |
| 129 | + const text = yield* Effect.tryPromise(() => | |
| 130 | + fetch(preset.url, { signal: AbortSignal.timeout(10_000) }).then( | |
| 131 | + (r) => r.text(), | |
| 132 | + ), | |
| 133 | + ); | |
| 134 | + const manifest = yield* extractGoogleDiscoveryManifest(text); | |
| 135 | + | |
| 136 | + expect(manifest.service).toBeTruthy(); | |
| 137 | + expect(manifest.version).toBeTruthy(); | |
| 138 | + expect(manifest.methods.length).toBeGreaterThan(0); | |
| 139 | + }), | |
| 140 | + { timeout: 15_000 }, | |
| 141 | + ); | |
| 142 | + } | |
| 143 | +}); | |
| 144 | + | |
| 145 | +// --------------------------------------------------------------------------- | |
| 146 | +// Detection — full executor pipeline, only for presets that don't need auth | |
| 147 | +// --------------------------------------------------------------------------- | |
| 148 | + | |
| 149 | +const publicPresets = allPresets.filter( | |
| 150 | + (p) => | |
| 151 | + // Skip auth-required endpoints that won't pass detection without credentials | |
| 152 | + !["github-graphql", "linear", "monday"].includes(p.id) && | |
| 153 | + // Skip endpoints where detection is flaky due to timeout or misdetection | |
| 154 | + // (these are detect() implementation issues, not preset issues) | |
| 155 | + !["firecrawl", "gitlab"].includes(p.id), | |
| 156 | +); | |
| 157 | + | |
| 158 | +describe("public preset URLs are detected by the correct plugin", () => { | |
| 159 | + const makeExecutor = () => | |
| 160 | + createExecutor( | |
| 161 | + makeTestConfig({ | |
| 162 | + plugins: [ | |
| 163 | + openApiPlugin(), | |
| 164 | + mcpPlugin(), | |
| 165 | + graphqlPlugin(), | |
| 166 | + googleDiscoveryPlugin(), | |
| 167 | + ] as const, | |
| 168 | + }), | |
| 169 | + ); | |
| 170 | + | |
| 171 | + for (const preset of publicPresets) { | |
| 172 | + it.effect( | |
| 173 | + `[${preset.plugin}] ${preset.name}`, | |
| 174 | + () => | |
| 175 | + Effect.gen(function* () { | |
| 176 | + const executor = yield* makeExecutor(); | |
| 177 | + const results = yield* executor.sources.detect(preset.url); | |
| 178 | + | |
| 179 | + expect( | |
| 180 | + results.length, | |
| 181 | + `No detection results for ${preset.name} (${preset.url})`, | |
| 182 | + ).toBeGreaterThan(0); | |
| 183 | + | |
| 184 | + const expectedKinds: Record<string, string> = { | |
| 185 | + openapi: "openapi", | |
| 186 | + mcp: "mcp", | |
| 187 | + graphql: "graphql", | |
| 188 | + "google-discovery": "googleDiscovery", | |
| 189 | + }; | |
| 190 | + const best = results[0]!; | |
| 191 | + expect(best.kind).toBe(expectedKinds[preset.plugin]); | |
| 192 | + }), | |
| 193 | + { timeout: 30_000 }, | |
| 194 | + ); | |
| 195 | + } | |
| 196 | +}); | |
| 197 | + | |
| 198 | +// --------------------------------------------------------------------------- | |
| 199 | +// Icons | |
| 200 | +// --------------------------------------------------------------------------- | |
| 201 | + | |
| 202 | +describe("preset icons are reachable", () => { | |
| 203 | + const presetsWithIcons = allPresets.filter((p) => p.icon); | |
| 204 | + for (const preset of presetsWithIcons) { | |
| 205 | + it.effect( | |
| 206 | + `[${preset.plugin}] ${preset.name} icon`, | |
| 207 | + () => | |
| 208 | + Effect.gen(function* () { | |
| 209 | + const response = yield* Effect.tryPromise(() => | |
| 210 | + fetch(preset.icon!, { | |
| 211 | + method: "GET", | |
| 212 | + signal: AbortSignal.timeout(10_000), | |
| 213 | + headers: { "User-Agent": "executor-preset-test" }, | |
| 214 | + redirect: "follow", | |
| 215 | + }), | |
| 216 | + ); | |
| 217 | + expect( | |
| 218 | + response.ok, | |
| 219 | + `${preset.name} icon returned ${response.status}`, | |
| 220 | + ).toBe(true); | |
| 221 | + }), | |
| 222 | + { timeout: 15_000 }, | |
| 223 | + ); | |
| 224 | + } | |
| 225 | +}); | |
| @@ -0,0 +1,7 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | + | |
| 3 | +export default defineConfig({ | |
| 4 | + test: { | |
| 5 | + include: ["tests/**/*.test.ts"], | |
| 6 | + }, | |
| 7 | +}); | |