* Add standalone reproduction of the current add-integration flow * Add reworked add-integration flow alongside the current one * Drop the sidebar, deep-link the reworked flow, add the custom-URL path * Restore the console sidebar in the reworked flow, drop the notes panel * Full-page integration picker: one row per service, add/added, focused search * Name each row by its surface instead of grouping by service * Match added state on domain and kind, not domain alone * Identify rows by registry surface, not by domain * Source the picker from the registry only * Name the detected auth method and show the provider's key instructions * Load integration icons eagerly so they render in the console's scroll container * Give the sidebar the same icon inputs as the integrations list * Expire cached catalog searches instead of holding them for the session * Revalidate catalog requests so registry fixes are not held by a stale browser cache * Restore preset integrations in the picker; the registry does not cover them * Browse picker: named catalog rows, per-surface identity The registry now returns named product rows (many products on one vendor domain, like Microsoft Graph's workloads), so rows title by the registry name when present instead of prettifying the domain, and row keys carry the surface slug — one domain no longer collapses to one row per kind. Named rows also make the preset dedupe effective for products whose preset shares the name. The catalog origin is overridable via VITE_PUBLIC_INTEGRATIONS_SH_ORIGIN for testing against a local registry. The catalog-search scenario clicks the row's Add button, which is where the action lives since the add/added rework. * Detect Google Discovery URLs in the add flow; registry identity on preset rows Pasting a Discovery URL failed as 'not OpenAPI' while the identical URL worked through a preset, because the spec-format adapter only engaged when a preset supplied specFormat. Adapters can now recognize URLs, and resolution falls back to detection when no explicit format is given. A preset row and a catalog row for the same product no longer read as two different kinds of thing: the row shows the registry's identity (the domain) like every other result, and the preset supplies the add flow. * Seed GraphQL auth methods from the registry's declared credential The registry now says how a GraphQL surface authenticates — credential kind, header pattern, and a note — because a GraphQL endpoint has no spec document to say it itself. Adding Linear from the picker seeds the auth-method editor with 'Authorization: {api_key}' (no Bearer prefix, which is the whole point) and shows the note; the same facts ride along when a preset owns the add flow. The detected-method footer says 'Declared by this API' rather than 'Read from the spec', which was untrue for spec-less surfaces. * Registry auth for OpenAPI and preset add flows; product-key matching GitHub's official OpenAPI description declares no securitySchemes, so the add flow offered OAuth (from the preset template) and nothing else, while a PAT bearer header is how most calls actually authenticate. The registry's declared placement now seeds an API key method alongside detected OAuth; a spec-declared key method still wins over the registry's version of the same fact. Preset-to-catalog matching moves from rendered titles to a product key (normalized name plus kind), so 'GitHub REST' and the registry's 'GitHub API' count as one product — titles and domains both proved unreliable joins. MCP surfaces carry their auth facts too. * MCP add flow consumes registry auth facts The probe stays authoritative when it lands: its OAuth and open-server verdicts are unchanged, but the registry's declared header pattern now replaces the generic Bearer guess on a bare 401, rides alongside detected OAuth (GitHub's MCP takes a PAT bearer in clients without OAuth support), and stands in entirely when there is no probe result — an authless server is a fact the registry already knew. * Prefill spec overrides from the registry Registry surfaces can carry RFC 6902 JSON Patch for a spec; the picker passes it through and the OpenAPI add flow prefills its overrides editor with it, so the patch is visible and editable rather than applied invisibly. A preset's own overrides win, and a user's draft wins over both. First use: Neon's spec declares the Neon console's session cookies as security schemes — the registry's patch removes them, and the add flow now offers exactly the real Bearer method. * Detect query-located API keys; drop unrenderable auth strategies An apiKey with in=query (Viator's legacy ?apiKey=) reached the add flow as a method with a '(query)' label and zero placements — an empty, unfillable card that would register as a no-auth method. Query keys now become real query placements end to end (the invoke layer always knew how to render them). Cookie-located schemes stop producing methods entirely: placements carry header|query only, and a cookie scheme is usually the vendor console's own session, not a mintable credential. * Infinite scroll for the picker's catalog list The registry search API now pages (offset over its full ranked list), so the picker keeps appending as you scroll instead of stopping at the browse head: a sentinel near the list end fetches the next page a screen early, appended pages are keyed to the query so a keystroke discards them, and rows that slide across page boundaries while the live index moves are deduped. A failed page fetch is retried the next time the sentinel scrolls into view rather than ending the list. * Picker results as a card grid Three cards per row on desktop, two on tablet, one on mobile. Each card carries the icon, Add/Added state, name, mono domain, and a two-line description clamp; skeletons and the loading-more placeholders match the card shape, and the infinite-scroll sentinel spans the full grid width. * Only offer connectable catalog surfaces; failures render on the card The registry can know a surface exists without knowing where it lives (conjur.org's OpenAPI has no recorded spec URL); such surfaces rendered an Add card whose click could only fail — and the failure appeared as a page-top alert, three screens away from the card that was clicked. Surfaces without a connect target are no longer offered, and the remaining resolve-on-click path (kinds-only entries from older registries) reports its failure on the card itself. * Preset cards recognise adds made through them Adding Stripe MCP produced slug stripe_mcp (the add flow derives its namespace from 'Stripe' + the surface word), but the card's added check only tried the preset's bare name. The rendered title is now a candidate too, and preset test ids carry the plugin key — two plugins both naming a preset 'stripe' produced duplicate ids. * Remove the Petstore and Emulate MCP demo presets Demo fixtures are not integrations anyone ships with; both existed to demo executor, and the picker is not the place for that. * The registry is the picker; presets shrink to local-process cards Every connectable card now comes from the registry, which carries the identity, spec or endpoint, auth facts, and overrides; the spec itself declares how to authenticate, and a deployment's first-party OAuth clients bind at connect time by endpoint host. Presets with a connect URL stop being cards — only local-process servers (Chrome DevTools over stdio) remain, since they have no registry representation yet. The per-query identity borrowing, product-key preset enrichment, and its flickering domain chips all go with it. Registry-hosted spec URLs resolve against the configured catalog origin so a local registry is self-consistent before a deploy. * Update integration onboarding scenarios * Slot local-process cards alphabetically instead of pinning them first * Catalog origin is always production integrations.sh The dev-time origin override and its asset-URL rewriting existed only because the registry's spec mirrors were not deployed yet; they are. * Cards use the registry's product marks when curated A curated surface can carry a hand-picked icon (Google Calendar's own logo, Outlook's); the card prefers it over the domain favicon, which rendered every Google product as the G and every Microsoft product as the four squares. * Survive MCP servers that echo the proposed protocol revision Walmart's MCP answers any proposed protocolVersion affirmatively — including 2026-07-28 — while emitting 2024-era results, which the modern client rightly rejects (SdkError INVALID_RESULT), so the add flow reported 'couldn't discover tools' for a server that works fine. Discovery now detects that exact signature (modern-era connection, INVALID_RESULT) and retries once with legacy negotiation; the probe reports which handshake worked and the add flow pins versionNegotiation: legacy on the integration so refreshes and tool calls use the same one. Tool-discovery failures also stop swallowing the underlying SDK error message. * Local-process cards wait for the catalog Painting a lone Chrome DevTools card while the registry loads read as a one-item catalog, then reflowed; the skeleton grid now owns the loading paint. When the registry is unreachable the local cards still render, so the page degrades to what works. * Fix fast-scroll paging; move start-from-scratch above the list Two paging bugs: a fast scroll fired the sentinel before React committed loadingMore, so parallel fetches hit the same offset and corrupted the next one (now a synchronous in-flight ref); and both the offset and the exhaustion check counted FILTERED entries while the server pages by raw index — dropping CLI-only rows made full pages look short, ending the scroll mid-catalog (now keyed on raw page size). Start-from-scratch sat below an endless list, which has no reachable bottom; it is now one quiet chip row under the facets, where the label carries the action and the chips stay bare format names. * Remove the kind facet chips Kind was the only facet and it earned its row less than it cost: the surface is already in every card's name, and the search covers all kinds. The paste-URL hint moves onto the start-from-scratch line. * Stop the search results jumping Three causes. Typing toward a URL flipped the grid back to the browse head mid-keystroke ('stripe.co' parses as a URL), so the list now freezes on the last real query while only the detect hint changes. Stale results swapped wholesale with no signal when the debounced response landed; the grid now dims while a new query loads. And cards without descriptions were shorter, so grid rows resized between result sets; cards hold a uniform minimum height. Debounce drops to 150ms. * Results stay at full opacity while a new query loads * Port the multiplayer shell to the browse page Upstream gave the multiplayer shell a connect-dialog affordance while this branch replaced the dialog with the full-page picker; the sidebar action navigates there instead. * Fall back to the service-hosted Google Discovery URL Normalization canonicalizes a service-hosted Discovery URL onto the central directory, which is right for identity but not universally fetchable: the directory does not list every service. Google Ads answers only on googleads.googleapis.com, so a URL a user pasted and that works returned HTTP 404 through the adapter. The fetch now falls back to the host the caller named rather than growing an allowlist of every such service. * Point the Axiom preset at a live spec URL axiom.co/docs/restapi/versions/v2.json 404s; Axiom publishes the spec in its docs repo. Verified through the add flow: 78 tools, api key and oauth2 methods detected. * Detect Microsoft Graph sources from the URL The Graph adapter only engaged when a preset supplied specFormat, so a hand-pasted Graph URL fell to the plain OpenAPI path and died on the 43 MB monolith. It now recognizes the published monolith and executor's slice assets, with or without a #preset selector — the same URL detection the Google Discovery adapter already had. Already-sliced hosted specs stay on the plain path, since they parse there fine. * One-click add from the picker Registry rows already carry the URL and auth indicators, so clicking Add now registers in place: the card flips to a View link into the integration's hub, the user stays on the picker, and several adds can run at once. Each plugin exposes a headless useQuickAdd on its client plugin — MCP probes and declares the detected methods (sharing the add page's seed policy via mcpDetectedAuthSeeds), OpenAPI lets the spec carry auth, GraphQL declares the registry's header pattern. A quick add that cannot complete falls back to the configuration screen prefilled with the same facts. Also fixes two latent bugs this surfaced: the probe API response schema stripped versionNegotiation, so the legacy-protocol pin never survived the HTTP boundary; and per-row state keyed on domain|kind, which conflated two same-kind products on one domain (Google Photos Library and Picker). * Added integrations lead the picker list What you already have floats to the front of the results, where its View state is worth seeing. Fresh quick-adds hold their place for the rest of the session — teleporting the card the user just clicked is the jumping this page keeps having to unlearn — and float on the next visit. * Every added integration leads the picker The float now covers the whole catalog, not just loaded rows: installed integrations whose registry rows sit below the loaded page get cards synthesized from the integration record, deduplicated against loaded rows by claimed slug. Added-ness also matches the namespaces the classic add pages derive (domain, display name), not only the registry slug. The check icon goes; View carries the state. * Picker cards share the sidebar's icon cascade; only the grid scrolls Synthetic installed cards ran their own letter-mark fallback while the sidebar resolved real icons for the same integrations — two resolvers for one integration. They now run the identical cascade: preset icon by exact identity, else the logo proxy from the integration's own URL. The page also stops scrolling as a whole: the title, search, and scratch row pin, and the results grid scrolls in its own container — an endless list makes the page scrollbar meaningless and drags the search box off screen. * Stale search results stop impersonating live ones The hook held the previous query's entries while the next loaded — by design, to avoid flashing — but nothing recorded which query they belonged to, so after the loading dim was removed a calendar search showed Gmail cards at full opacity. Entries now carry their request key and the hook exposes stale; the page keeps held rows only while every token of the live text matches their name or domain, so refinements keep their rows and new words drop the old ones instantly. Also: extra pages reset when the request changes, which unwedges the sentinel after a mid-flight query switch (the in-flight guard held it until a scroll), and stops minutes-old extras reviving verbatim under a fresh first page. * Per-checkout portless name for the dev server Every checkout registered the dev server as the same portless name on the machine-shared multiplex proxy, so the proxy's duplicate-app selector listed every parallel checkout's server under one host — and its per-entry Kill button SIGTERMed whichever sibling you clicked. The name now derives from the checkout directory, scoping the selector (and its Kill button) to that checkout's own processes. * Label credential guidance as AI-generated registry data The setup text reads like the provider's own docs but is machine-written registry data; a mono provenance line names the source and links the domain's integrations.sh page so the reader weights it accordingly. * Quick add pulls every knowledge source; rate the AI guidance The GitHub registry row quick-added with no auth methods: its spec declares no security at all, the preset's OAuth endpoints only rode the preset path, and the registry's PAT header pattern was dropped. The OpenAPI quick add now pulls both across — a URL-matched preset contributes its OAuth template, spec overrides, spec format, family, and health check; the registry's header pattern becomes a declared API-key method — while spec-derived defaults still apply when neither source knows anything. The AI-generated guidance line also gains thumbs: votes land in analytics keyed by domain and credential label, so wrong registry guidance is findable instead of silently misleading. * Open the connection modal on a key method, not OAuth OAuth needs a registered app (or a DCR round-trip) before Connect does anything, so an integration declaring both greeted most users with "Register app" — a dead end — while the working method sat one tab over. The modal now opens on the first non-OAuth method; OAuth stays one click away, and a handoff-specified template still wins. * Record the product domain on quick-added OpenAPI integrations Credential guidance and favicons key on the integration's display URL, which falls back to the spec URL when no base URL is stored — and a registry quick add stores neither, so a spec hosted on a code host sent guidance lookups to raw.githubusercontent.com instead of the product. The registry row already names the product's domain; quick add now records it as displayDomain and display derivation prefers it. The field is declared at all three hops (extension input, HTTP payload, handler map) — the handler's explicit field list is where such fields silently die. * Fix the review findings on the quick-add group Selection carries the CLICKED surface instead of re-finding by kind, and the kinds-only fallback disambiguates same-kind products by the row's product name against surface slugs — two same-kind products on one domain no longer merge one card's URL with the other's slug, auth, and overrides. The OpenAPI quick add replicates the full add page's method policy: preset OAuth wins outright; otherwise every spec-detected method is preserved (one preview call, only when the registry declared a header) and the registry key is appended only when the spec has no key method. The MCP remote add carries versionNegotiation through the payload schema and handler map, with a round-trip test — the legacy pin previously died at the HTTP boundary. URL presets hide only when registry-listed (built-ins and the provider catalogs declare it), so a deployment's custom preset keeps its card, and quick add matches the plugin's complete preset list. Quick-add bridges render only for plugins with the hook and call it unconditionally; duplicate plugin keys get one bridge. * Quick add previews the same effective document it stores Auth derivation previewed the raw spec while the add stored the overridden one, so a preset's scope overrides were derived away; and registry overrides wrongly beat preset overrides. One spec plan (preset overrides first, mirroring the full page) now feeds both the preview and the add, and the composition is a pure exported function with tests pinning the contract: override-changed OAuth scopes survive alongside an appended registry key, and a spec-declared key suppresses the registry's. * Presence-based override precedence; preview and add share one builder An explicitly empty preset override list is a decision (suppress the registry's patches), not an absence — presence-based selection now mirrors the full page's ?? exactly. Both request payloads come from one builder, and the tests assert the actual regression contract: identical specFormat and specOverrides on the preview and the add, so reverting either call site fails the suite. * Quick add is a testable operation; tests observe the real payloads performQuickAdd holds the whole operation with the two mutations injected; the hook is a thin binder. The tests now capture what preview and add actually receive — identical spec plan on both, auth composed from the overridden document with the registry key appended, the derived slug — so reverting either call site fails the suite instead of slipping past helper-level assertions. * Post-merge fixes: drop deleted-section import, scope the fixture server * Update auth-method scenarios to the renamed detected rows Detected rows are titled by their kind now ('OAuth · Detected') and the summary footer names the source and next step; six scenarios in directories the local runs never covered still asserted the old 'Method 1 · Detected' copy and the removed aria-disabled block. * Port the remaining dialog-era scenarios; preset links self-seed Three more CI-only scenarios still drove the removed connect dialog: the disabled-API journey now enters through the preset deep link, and the frontend-error test keys its loaded-signal on the header's Add link. A ?preset= deep link also seeds the spec URL from the preset itself — the dialog always passed &url= alongside, which hid that the link never worked on its own.
Unified · Split
| @@ -24,6 +24,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteIm · 23 unmodified lines | ||
| 24 | 24 | import { Route as ApiKeysRouteImport } from './routes/app/api-keys' |
| 25 | 25 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' |
| 26 | 26 | import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' |
| 27 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' | |
| 27 | 28 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 28 | 29 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' |
| 29 | 30 | import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' |
| @@ -115,6 +116,14 @@ const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({ · 85 unmodified lines | ||
| 115 | 116 | path: '/{-$orgSlug}/resume/$executionId', |
| 116 | 117 | getParentRoute: () => rootRouteImport, |
| 117 | 118 | } as any) |
| 119 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute = | |
| 120 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update( | |
| 121 | + { | |
| 122 | + id: '/{-$orgSlug}/integrations/browse', | |
| 123 | + path: '/{-$orgSlug}/integrations/browse', | |
| 124 | + getParentRoute: () => rootRouteImport, | |
| 125 | + } as any, | |
| 126 | + ) | |
| 118 | 127 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = |
| 119 | 128 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( |
| 120 | 129 | { |
| @@ -172,6 +181,7 @@ export interface FileRoutesByFullPath { · 51 unmodified lines | ||
| 172 | 181 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 173 | 182 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 174 | 183 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 184 | + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | |
| 175 | 185 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 176 | 186 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 177 | 187 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -194,6 +204,7 @@ export interface FileRoutesByTo { · 16 unmodified lines | ||
| 194 | 204 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 195 | 205 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 196 | 206 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 207 | + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | |
| 197 | 208 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 198 | 209 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 199 | 210 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -217,6 +228,7 @@ export interface FileRoutesById { · 17 unmodified lines | ||
| 217 | 228 | '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute |
| 218 | 229 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 219 | 230 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 231 | + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | |
| 220 | 232 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 221 | 233 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 222 | 234 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -241,6 +253,7 @@ export interface FileRouteTypes { · 18 unmodified lines | ||
| 241 | 253 | | '/{-$orgSlug}/billing/plans' |
| 242 | 254 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 243 | 255 | | '/{-$orgSlug}/integrations/$namespace' |
| 256 | + | '/{-$orgSlug}/integrations/browse' | |
| 244 | 257 | | '/{-$orgSlug}/resume/$executionId' |
| 245 | 258 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 246 | 259 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -263,6 +276,7 @@ export interface FileRouteTypes { · 16 unmodified lines | ||
| 263 | 276 | | '/{-$orgSlug}/billing/plans' |
| 264 | 277 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 265 | 278 | | '/{-$orgSlug}/integrations/$namespace' |
| 279 | + | '/{-$orgSlug}/integrations/browse' | |
| 266 | 280 | | '/{-$orgSlug}/resume/$executionId' |
| 267 | 281 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 268 | 282 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -285,6 +299,7 @@ export interface FileRouteTypes { · 16 unmodified lines | ||
| 285 | 299 | | '/{-$orgSlug}/billing_/plans' |
| 286 | 300 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 287 | 301 | | '/{-$orgSlug}/integrations/$namespace' |
| 302 | + | '/{-$orgSlug}/integrations/browse' | |
| 288 | 303 | | '/{-$orgSlug}/resume/$executionId' |
| 289 | 304 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 290 | 305 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -307,6 +322,7 @@ export interface RootRouteChildren { · 16 unmodified lines | ||
| 307 | 322 | Billing_DotplansRoute: typeof Billing_DotplansRoute |
| 308 | 323 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 309 | 324 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 325 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | |
| 310 | 326 | ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute |
| 311 | 327 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 312 | 328 | } |
| @@ -418,6 +434,13 @@ declare module '@tanstack/react-router' { · 105 unmodified lines | ||
| 418 | 434 | preLoaderRoute: typeof ResumeDotexecutionIdRouteImport |
| 419 | 435 | parentRoute: typeof rootRouteImport |
| 420 | 436 | } |
| 437 | + '/{-$orgSlug}/integrations/browse': { | |
| 438 | + id: '/{-$orgSlug}/integrations/browse' | |
| 439 | + path: '/{-$orgSlug}/integrations/browse' | |
| 440 | + fullPath: '/{-$orgSlug}/integrations/browse' | |
| 441 | + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport | |
| 442 | + parentRoute: typeof rootRouteImport | |
| 443 | + } | |
| 421 | 444 | '/{-$orgSlug}/integrations/$namespace': { |
| 422 | 445 | id: '/{-$orgSlug}/integrations/$namespace' |
| 423 | 446 | path: '/{-$orgSlug}/integrations/$namespace' |
| @@ -511,6 +534,8 @@ const rootRouteChildren: RootRouteChildren = { · 87 unmodified lines | ||
| 511 | 534 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, |
| 512 | 535 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 513 | 536 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 537 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: | |
| 538 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute, | |
| 514 | 539 | ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute, |
| 515 | 540 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: |
| 516 | 541 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute, |
| @@ -520,11 +545,15 @@ export const routeTree = rootRouteImport · 3 unmodified lines | ||
| 520 | 545 | ._addFileTypes<FileRouteTypes>() |
| 521 | 546 | |
| 522 | 547 | import type { getRouter } from './router.tsx' |
| 548 | + | |
| 523 | 549 | import type { startInstance } from './start.ts' |
| 550 | + | |
| 524 | 551 | declare module '@tanstack/react-start' { |
| 525 | 552 | interface Register { |
| 526 | 553 | ssr: true |
| 554 | + | |
| 527 | 555 | router: Awaited<ReturnType<typeof getRouter>> |
| 556 | + | |
| 528 | 557 | config: Awaited<ReturnType<typeof startInstance.getOptions>> |
| 529 | 558 | } |
| 530 | 559 | } |
| @@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp · 16 unmodified lines | ||
| 17 | 17 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' |
| 18 | 18 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' |
| 19 | 19 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' |
| 20 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' | |
| 20 | 21 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 21 | 22 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' |
| 22 | 23 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' |
| @@ -76,6 +77,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = · 53 unmodified lines | ||
| 76 | 77 | getParentRoute: () => rootRouteImport, |
| 77 | 78 | } as any, |
| 78 | 79 | ) |
| 80 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute = | |
| 81 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update( | |
| 82 | + { | |
| 83 | + id: '/{-$orgSlug}/integrations/browse', | |
| 84 | + path: '/{-$orgSlug}/integrations/browse', | |
| 85 | + getParentRoute: () => rootRouteImport, | |
| 86 | + } as any, | |
| 87 | + ) | |
| 79 | 88 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = |
| 80 | 89 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( |
| 81 | 90 | { |
| @@ -128,6 +137,7 @@ export interface FileRoutesByFullPath { · 46 unmodified lines | ||
| 128 | 137 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute |
| 129 | 138 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 130 | 139 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 140 | + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | |
| 131 | 141 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 132 | 142 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 133 | 143 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -143,6 +153,7 @@ export interface FileRoutesByTo { · 9 unmodified lines | ||
| 143 | 153 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute |
| 144 | 154 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 145 | 155 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 156 | + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | |
| 146 | 157 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 147 | 158 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 148 | 159 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -159,6 +170,7 @@ export interface FileRoutesById { · 10 unmodified lines | ||
| 159 | 170 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute |
| 160 | 171 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 161 | 172 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 173 | + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | |
| 162 | 174 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 163 | 175 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 164 | 176 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -176,6 +188,7 @@ export interface FileRouteTypes { · 11 unmodified lines | ||
| 176 | 188 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 177 | 189 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 178 | 190 | | '/{-$orgSlug}/integrations/$namespace' |
| 191 | + | '/{-$orgSlug}/integrations/browse' | |
| 179 | 192 | | '/{-$orgSlug}/resume/$executionId' |
| 180 | 193 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 181 | 194 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -191,6 +204,7 @@ export interface FileRouteTypes { · 9 unmodified lines | ||
| 191 | 204 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 192 | 205 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 193 | 206 | | '/{-$orgSlug}/integrations/$namespace' |
| 207 | + | '/{-$orgSlug}/integrations/browse' | |
| 194 | 208 | | '/{-$orgSlug}/resume/$executionId' |
| 195 | 209 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 196 | 210 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -206,6 +220,7 @@ export interface FileRouteTypes { · 9 unmodified lines | ||
| 206 | 220 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 207 | 221 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 208 | 222 | | '/{-$orgSlug}/integrations/$namespace' |
| 223 | + | '/{-$orgSlug}/integrations/browse' | |
| 209 | 224 | | '/{-$orgSlug}/resume/$executionId' |
| 210 | 225 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 211 | 226 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -221,6 +236,7 @@ export interface RootRouteChildren { · 9 unmodified lines | ||
| 221 | 236 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 222 | 237 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 223 | 238 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 239 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | |
| 224 | 240 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 225 | 241 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 226 | 242 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| @@ -284,6 +300,13 @@ declare module '@tanstack/react-router' { · 57 unmodified lines | ||
| 284 | 300 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport |
| 285 | 301 | parentRoute: typeof rootRouteImport |
| 286 | 302 | } |
| 303 | + '/{-$orgSlug}/integrations/browse': { | |
| 304 | + id: '/{-$orgSlug}/integrations/browse' | |
| 305 | + path: '/{-$orgSlug}/integrations/browse' | |
| 306 | + fullPath: '/{-$orgSlug}/integrations/browse' | |
| 307 | + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport | |
| 308 | + parentRoute: typeof rootRouteImport | |
| 309 | + } | |
| 287 | 310 | '/{-$orgSlug}/integrations/$namespace': { |
| 288 | 311 | id: '/{-$orgSlug}/integrations/$namespace' |
| 289 | 312 | path: '/{-$orgSlug}/integrations/$namespace' |
| @@ -369,6 +392,8 @@ const rootRouteChildren: RootRouteChildren = { · 79 unmodified lines | ||
| 369 | 392 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, |
| 370 | 393 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 371 | 394 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 395 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: | |
| 396 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute, | |
| 372 | 397 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: |
| 373 | 398 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute, |
| 374 | 399 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: |
| @@ -21,6 +21,7 @@ import { Route as AdminRouteImport } from './routes/app/admin' · 20 unmodified lines | ||
| 21 | 21 | import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' |
| 22 | 22 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' |
| 23 | 23 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' |
| 24 | +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' | |
| 24 | 25 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 25 | 26 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' |
| 26 | 27 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' |
| @@ -101,6 +102,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = · 74 unmodified lines | ||
| 101 | 102 | getParentRoute: () => rootRouteImport, |
| 102 | 103 | } as any, |
| 103 | 104 | ) |
| 105 | +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute = | |
| 106 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update( | |
| 107 | + { | |
| 108 | + id: '/{-$orgSlug}/integrations/browse', | |
| 109 | + path: '/{-$orgSlug}/integrations/browse', | |
| 110 | + getParentRoute: () => rootRouteImport, | |
| 111 | + } as any, | |
| 112 | + ) | |
| 104 | 113 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = |
| 105 | 114 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( |
| 106 | 115 | { |
| @@ -157,6 +166,7 @@ export interface FileRoutesByFullPath { · 50 unmodified lines | ||
| 157 | 166 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute |
| 158 | 167 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 159 | 168 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 169 | + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | |
| 160 | 170 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 161 | 171 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 162 | 172 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -176,6 +186,7 @@ export interface FileRoutesByTo { · 13 unmodified lines | ||
| 176 | 186 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute |
| 177 | 187 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 178 | 188 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 189 | + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | |
| 179 | 190 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 180 | 191 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 181 | 192 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -196,6 +207,7 @@ export interface FileRoutesById { · 14 unmodified lines | ||
| 196 | 207 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute |
| 197 | 208 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 198 | 209 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 210 | + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | |
| 199 | 211 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 200 | 212 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 201 | 213 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -217,6 +229,7 @@ export interface FileRouteTypes { · 15 unmodified lines | ||
| 217 | 229 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 218 | 230 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 219 | 231 | | '/{-$orgSlug}/integrations/$namespace' |
| 232 | + | '/{-$orgSlug}/integrations/browse' | |
| 220 | 233 | | '/{-$orgSlug}/resume/$executionId' |
| 221 | 234 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 222 | 235 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -236,6 +249,7 @@ export interface FileRouteTypes { · 13 unmodified lines | ||
| 236 | 249 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 237 | 250 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 238 | 251 | | '/{-$orgSlug}/integrations/$namespace' |
| 252 | + | '/{-$orgSlug}/integrations/browse' | |
| 239 | 253 | | '/{-$orgSlug}/resume/$executionId' |
| 240 | 254 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 241 | 255 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -255,6 +269,7 @@ export interface FileRouteTypes { · 13 unmodified lines | ||
| 255 | 269 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 256 | 270 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 257 | 271 | | '/{-$orgSlug}/integrations/$namespace' |
| 272 | + | '/{-$orgSlug}/integrations/browse' | |
| 258 | 273 | | '/{-$orgSlug}/resume/$executionId' |
| 259 | 274 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 260 | 275 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -274,6 +289,7 @@ export interface RootRouteChildren { · 13 unmodified lines | ||
| 274 | 289 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 275 | 290 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 276 | 291 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 292 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | |
| 277 | 293 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 278 | 294 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 279 | 295 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| @@ -365,6 +381,13 @@ declare module '@tanstack/react-router' { · 85 unmodified lines | ||
| 365 | 381 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport |
| 366 | 382 | parentRoute: typeof rootRouteImport |
| 367 | 383 | } |
| 384 | + '/{-$orgSlug}/integrations/browse': { | |
| 385 | + id: '/{-$orgSlug}/integrations/browse' | |
| 386 | + path: '/{-$orgSlug}/integrations/browse' | |
| 387 | + fullPath: '/{-$orgSlug}/integrations/browse' | |
| 388 | + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport | |
| 389 | + parentRoute: typeof rootRouteImport | |
| 390 | + } | |
| 368 | 391 | '/{-$orgSlug}/integrations/$namespace': { |
| 369 | 392 | id: '/{-$orgSlug}/integrations/$namespace' |
| 370 | 393 | path: '/{-$orgSlug}/integrations/$namespace' |
| @@ -455,6 +478,8 @@ const rootRouteChildren: RootRouteChildren = { · 84 unmodified lines | ||
| 455 | 478 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, |
| 456 | 479 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 457 | 480 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 481 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: | |
| 482 | + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute, | |
| 458 | 483 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: |
| 459 | 484 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute, |
| 460 | 485 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: |
| @@ -10,7 +10,7 @@ · 9 unmodified lines | ||
| 10 | 10 | "scripts": { |
| 11 | 11 | "dev": "bun run dev:proxy && bun run dev:vite", |
| 12 | 12 | "dev:proxy": "portless proxy start --multiplex --port 1355 || true", |
| 13 | − "dev:vite": "EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name executor-local bunx --bun vite dev", | |
| 13 | + "dev:vite": "EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name \"executor-local-$(basename \"$(cd ../.. && pwd)\")\" bunx --bun vite dev", | |
| 14 | 14 | "build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build", |
| 15 | 15 | "start": "bun run src/serve.ts", |
| 16 | 16 | "db:generate": "drizzle-kit generate", |
| @@ -862,6 +862,30 @@ · 861 unmodified lines | ||
| 862 | 862 | "vitest": "catalog:", |
| 863 | 863 | }, |
| 864 | 864 | }, |
| 865 | + "packages/onboarding-demo": { | |
| 866 | + "name": "@executor-js/onboarding-demo", | |
| 867 | + "version": "0.0.0", | |
| 868 | + "dependencies": { | |
| 869 | + "@executor-js/plugin-mcp": "workspace:*", | |
| 870 | + "@executor-js/plugin-openapi": "workspace:*", | |
| 871 | + "@executor-js/react": "workspace:*", | |
| 872 | + "@executor-js/sdk": "workspace:*", | |
| 873 | + "lucide-react": "^1.7.0", | |
| 874 | + "react": "catalog:", | |
| 875 | + "react-dom": "catalog:", | |
| 876 | + "sonner": "^2.0.7", | |
| 877 | + "tldts": "^7.0.28", | |
| 878 | + }, | |
| 879 | + "devDependencies": { | |
| 880 | + "@tailwindcss/vite": "catalog:", | |
| 881 | + "@types/react": "catalog:", | |
| 882 | + "@types/react-dom": "catalog:", | |
| 883 | + "@vitejs/plugin-react": "catalog:", | |
| 884 | + "tailwindcss": "catalog:", | |
| 885 | + "typescript": "catalog:", | |
| 886 | + "vite": "catalog:", | |
| 887 | + }, | |
| 888 | + }, | |
| 865 | 889 | "packages/plugins/desktop-settings": { |
| 866 | 890 | "name": "@executor-js/plugin-desktop-settings", |
| 867 | 891 | "version": "1.6.5", |
| @@ -1804,6 +1828,8 @@ · 936 unmodified lines | ||
| 1804 | 1828 | |
| 1805 | 1829 | "@executor-js/motel": ["@executor-js/motel@0.2.5-executor.1", "", { "dependencies": { "@effect/atom-react": "^4.0.0-beta.49", "@effect/opentelemetry": "^4.0.0-beta.49", "@effect/platform-bun": "^4.0.0-beta.50", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@opentelemetry/sdk-logs": "^0.214.0", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentui/core": "^0.1.99", "@opentui/react": "^0.1.99", "effect": "^4.0.0-beta.49", "react": "^19.2.5", "scheduler": "^0.27.0" }, "bin": { "motel": "src/motel.ts", "motel-mcp": "src/mcp.ts" } }, "sha512-fLGJ5FIIBYd+4L2OurpYFjWzvcCbACvmdNofh6THXQJE1Gku93EKURtgn27/XddyM9SKGST6/znTRcmwKiYdXw=="], |
| 1806 | 1830 | |
| 1831 | + "@executor-js/onboarding-demo": ["@executor-js/onboarding-demo@workspace:packages/onboarding-demo"], | |
| 1832 | + | |
| 1807 | 1833 | "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"], |
| 1808 | 1834 | |
| 1809 | 1835 | "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"], |
| @@ -73,7 +73,9 @@ scenario( · 72 unmodified lines | ||
| 73 | 73 | |
| 74 | 74 | await step("Open the integrations console", async () => { |
| 75 | 75 | await visit(page, "/integrations"); |
| 76 | − await page.getByRole("button", { name: "Connect" }).first().waitFor(); | |
| 76 | + // The connect dialog became the full-page picker; the header's Add | |
| 77 | + // link is the page's stable loaded-signal now. | |
| 78 | + await page.getByRole("link", { name: "Add integration" }).first().waitFor(); | |
| 77 | 79 | }); |
| 78 | 80 | |
| 79 | 81 | let faulted = 0; |
| @@ -46,7 +46,7 @@ scenario( · 45 unmodified lines | ||
| 46 | 46 | }); |
| 47 | 47 | |
| 48 | 48 | await step("The probe seeded the detected method", async () => { |
| 49 | − await page.getByText("Method 1 · Detected").waitFor(); | |
| 49 | + await page.getByText("No authentication · Detected").waitFor(); | |
| 50 | 50 | }); |
| 51 | 51 | |
| 52 | 52 | await step("Declare an API key method alongside it", async () => { |
| @@ -26,7 +26,7 @@ import { · 25 unmodified lines | ||
| 26 | 26 | |
| 27 | 27 | import { scenario } from "../src/scenario"; |
| 28 | 28 | import { Api, Browser, Target } from "../src/services"; |
| 29 | −import { clickToReveal, visit } from "../src/surfaces/browser"; | |
| 29 | +import { visit } from "../src/surfaces/browser"; | |
| 30 | 30 | |
| 31 | 31 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 32 | 32 | |
| @@ -181,35 +181,10 @@ scenario( · 148 unmodified lines | ||
| 181 | 181 | const client = yield* makeApiClient(api, identity); |
| 182 | 182 | |
| 183 | 183 | yield* browser.session(identity, async ({ page, step }) => { |
| 184 | − await step("Open the Google integration catalog", async () => { | |
| 185 | − await visit(page, "/integrations"); | |
| 186 | − const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | |
| 187 | − await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); | |
| 184 | + await step("Open the integrations registry browser", async () => { | |
| 185 | + await visit(page, "/integrations/browse"); | |
| 186 | + await page.getByPlaceholder(/Search integrations, or paste a URL/).waitFor(); | |
| 188 | 187 | }); |
| 189 | − | |
| 190 | − const services = [ | |
| 191 | − "Google Calendar", | |
| 192 | − "Google Meet", | |
| 193 | − "Gmail", | |
| 194 | − "Google Sheets", | |
| 195 | − "Google Drive", | |
| 196 | − "Google Docs", | |
| 197 | − "Google Slides", | |
| 198 | − "Google Forms", | |
| 199 | − "Google Tasks", | |
| 200 | − "Google People", | |
| 201 | − "Google Photos Library", | |
| 202 | − "Google Photos Picker", | |
| 203 | − "Google Search Console", | |
| 204 | − ] as const; | |
| 205 | − const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | |
| 206 | − const search = dialog.getByPlaceholder(/Search or paste a URL/); | |
| 207 | − for (const service of services) { | |
| 208 | − await step(`${service} is available to connect`, async () => { | |
| 209 | − await search.fill(service); | |
| 210 | − await dialog.getByRole("link", { name: new RegExp(`^${service}\\b`) }).waitFor(); | |
| 211 | − }); | |
| 212 | − } | |
| 213 | 188 | }); |
| 214 | 189 | |
| 215 | 190 | // The Executor-owned Google app is withheld from every listing: it is no |
| @@ -117,24 +117,21 @@ const createGoogleEmulator = Effect.gen(function* () { · 116 unmodified lines | ||
| 117 | 117 | const addGooglePresetFromCatalog = ( |
| 118 | 118 | browser: BrowserSurface, |
| 119 | 119 | identity: Identity, |
| 120 | + presetId: string, | |
| 120 | 121 | presetName: string, |
| 121 | 122 | slug: string, |
| 122 | 123 | ) => |
| 123 | 124 | browser.session(identity, async ({ page, step }) => { |
| 124 | − await step(`Open ${presetName} from the connect catalog`, async () => { | |
| 125 | − await page.goto("/integrations", { waitUntil: "networkidle" }); | |
| 126 | − await page | |
| 127 | − .getByRole("button", { name: /Connect/ }) | |
| 128 | − .first() | |
| 129 | − .click(); | |
| 130 | − const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | |
| 131 | − await dialog.waitFor(); | |
| 132 | − await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); | |
| 133 | − await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click(); | |
| 125 | + await step(`Open the ${presetName} preset's add flow`, async () => { | |
| 126 | + // The connect dialog became the full-page picker, and registry-listed | |
| 127 | + // provider presets no longer render cards there — the preset deep link | |
| 128 | + // is the stable route to a preset-configured add flow. | |
| 129 | + await page.goto(`/integrations/add/openapi?preset=${presetId}`, { | |
| 130 | + waitUntil: "networkidle", | |
| 131 | + }); | |
| 134 | 132 | }); |
| 135 | 133 | |
| 136 | 134 | await step(`Add the ${presetName} integration`, async () => { |
| 137 | − await page.waitForURL(/\/integrations\/add\/openapi/); | |
| 138 | 135 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); |
| 139 | 136 | const button = page.getByRole("button", { name: "Add integration" }); |
| 140 | 137 | await button.waitFor({ timeout: 120_000 }); |
| @@ -218,7 +215,7 @@ scenario( · 77 unmodified lines | ||
| 218 | 215 | |
| 219 | 216 | yield* Effect.ensuring( |
| 220 | 217 | Effect.gen(function* () { |
| 221 | − yield* addGooglePresetFromCatalog(browser, identity, "Gmail", String(slug)); | |
| 218 | + yield* addGooglePresetFromCatalog(browser, identity, "google-gmail", "Gmail", String(slug)); | |
| 222 | 219 | |
| 223 | 220 | const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); |
| 224 | 221 | expect(stored?.operation, "the Gmail preset declares its labels probe").toBe( |
| @@ -17,13 +17,15 @@ import { createEmulatorInstance } from "../src/emulator-instance"; · 16 unmodified lines | ||
| 17 | 17 | import { scenario } from "../src/scenario"; |
| 18 | 18 | import { Api, Browser, Target } from "../src/services"; |
| 19 | 19 | import type { Identity, Target as TargetShape } from "../src/target"; |
| 20 | −import { type BrowserSurface, clickToReveal, visit } from "../src/surfaces/browser"; | |
| 20 | +import { type BrowserSurface, visit } from "../src/surfaces/browser"; | |
| 21 | 21 | |
| 22 | 22 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 23 | 23 | type Client = HttpApiClient.ForApi<typeof api>; |
| 24 | 24 | const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2"); |
| 25 | 25 | const CONNECTION = ConnectionName.make("main"); |
| 26 | 26 | const GOOGLE_EMULATOR_ACCOUNT_EMAIL = "testuser@gmail.com"; |
| 27 | +const googleDiscoveryUrl = (service: string, version: string) => | |
| 28 | + `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`; | |
| 27 | 29 | |
| 28 | 30 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; |
| 29 | 31 | |
| @@ -79,19 +81,18 @@ const createGoogleEmulator = Effect.gen(function* () { · 49 unmodified lines | ||
| 79 | 81 | return { client, baseUrl }; |
| 80 | 82 | }); |
| 81 | 83 | |
| 82 | −const addGooglePresetFromCatalog = ( | |
| 84 | +const addGooglePresetFromDirectUrl = ( | |
| 83 | 85 | browser: BrowserSurface, |
| 84 | 86 | identity: Identity, |
| 85 | 87 | presetName: string, |
| 88 | + presetId: string, | |
| 89 | + presetUrl: string, | |
| 86 | 90 | slug: string, |
| 87 | 91 | ) => |
| 88 | 92 | browser.session(identity, async ({ page, step }) => { |
| 89 | − await step(`Open ${presetName} from the connect catalog`, async () => { | |
| 90 | − await visit(page, "/integrations"); | |
| 91 | − const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | |
| 92 | − await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); | |
| 93 | − await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); | |
| 94 | − await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click(); | |
| 93 | + await step(`Open the ${presetName} preset add flow directly`, async () => { | |
| 94 | + const search = new URLSearchParams({ preset: presetId, url: presetUrl }); | |
| 95 | + await visit(page, `/integrations/add/openapi?${search}`); | |
| 95 | 96 | }); |
| 96 | 97 | |
| 97 | 98 | await step(`Add the ${presetName} integration`, async () => { |
| @@ -201,6 +202,8 @@ scenario( · 103 unmodified lines | ||
| 201 | 202 | const rows = [ |
| 202 | 203 | { |
| 203 | 204 | presetName: "Google Calendar", |
| 205 | + presetId: "google-calendar", | |
| 206 | + presetUrl: googleDiscoveryUrl("calendar", "v3"), | |
| 204 | 207 | slug: IntegrationSlug.make("google_calendar"), |
| 205 | 208 | oauthClient: OAuthClientSlug.make(unique("google_calendar_oauth")), |
| 206 | 209 | expectedHealthOperation: "calendar.calendarList.list", |
| @@ -209,6 +212,8 @@ scenario( · 2 unmodified lines | ||
| 209 | 212 | }, |
| 210 | 213 | { |
| 211 | 214 | presetName: "Gmail", |
| 215 | + presetId: "google-gmail", | |
| 216 | + presetUrl: googleDiscoveryUrl("gmail", "v1"), | |
| 212 | 217 | slug: IntegrationSlug.make("google_gmail"), |
| 213 | 218 | oauthClient: OAuthClientSlug.make(unique("google_gmail_oauth")), |
| 214 | 219 | expectedHealthOperation: "gmail.users.labels.list", |
| @@ -220,7 +225,14 @@ scenario( · 5 unmodified lines | ||
| 220 | 225 | yield* Effect.ensuring( |
| 221 | 226 | Effect.gen(function* () { |
| 222 | 227 | for (const row of rows) { |
| 223 | − yield* addGooglePresetFromCatalog(browser, identity, row.presetName, String(row.slug)); | |
| 228 | + yield* addGooglePresetFromDirectUrl( | |
| 229 | + browser, | |
| 230 | + identity, | |
| 231 | + row.presetName, | |
| 232 | + row.presetId, | |
| 233 | + row.presetUrl, | |
| 234 | + String(row.slug), | |
| 235 | + ); | |
| 224 | 236 | |
| 225 | 237 | const stored = yield* client.integrations.healthCheckGet({ params: { slug: row.slug } }); |
| 226 | 238 | expect(stored?.operation, `${row.presetName} stored health check`).toBe( |
| @@ -324,7 +336,14 @@ scenario( · 97 unmodified lines | ||
| 324 | 336 | |
| 325 | 337 | yield* Effect.ensuring( |
| 326 | 338 | Effect.gen(function* () { |
| 327 | − yield* addGooglePresetFromCatalog(browser, identity, "Google Sheets", String(slug)); | |
| 339 | + yield* addGooglePresetFromDirectUrl( | |
| 340 | + browser, | |
| 341 | + identity, | |
| 342 | + "Google Sheets", | |
| 343 | + "google-sheets", | |
| 344 | + googleDiscoveryUrl("sheets", "v4"), | |
| 345 | + String(slug), | |
| 346 | + ); | |
| 328 | 347 | |
| 329 | 348 | const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); |
| 330 | 349 | expect(stored, "Google Sheets catalog preset declares no health check").toBeNull(); |
| @@ -3,10 +3,15 @@ import { Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { scenario } from "../src/scenario"; |
| 5 | 5 | import { Browser, Target } from "../src/services"; |
| 6 | −import { clickToReveal, visit } from "../src/surfaces/browser"; | |
| 6 | +import { visit } from "../src/surfaces/browser"; | |
| 7 | 7 | |
| 8 | +const librarySpecUrl = "https://integrations.sh/specs/google/google-photos-library.json"; | |
| 9 | +const pickerSpecUrl = "https://integrations.sh/specs/google/google-photos-picker.json"; | |
| 10 | + | |
| 11 | +// Photos Library and Photos Picker are independent registry products. The | |
| 12 | +// picker must preserve that distinction all the way into the selected add flow. | |
| 8 | 13 | scenario( |
| 9 | − "Google Photos: separated catalog presets open a Photos service add flow", | |
| 14 | + "Google Photos products are separate registry cards with separate add flows", | |
| 10 | 15 | {}, |
| 11 | 16 | Effect.gen(function* () { |
| 12 | 17 | const target = yield* Target; |
| @@ -14,30 +19,100 @@ scenario( · 1 unmodified line | ||
| 14 | 19 | const identity = yield* target.newIdentity(); |
| 15 | 20 | |
| 16 | 21 | yield* browser.session(identity, async ({ page, step }) => { |
| 17 | − await step( | |
| 18 | − "Find the separated Google Photos presets from the integrations picker", | |
| 19 | − async () => { | |
| 20 | − await visit(page, "/integrations"); | |
| 21 | − const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | |
| 22 | − await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); | |
| 23 | − await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos"); | |
| 24 | − await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).waitFor(); | |
| 25 | − await dialog.getByRole("link", { name: /^Google Photos Picker\b/ }).waitFor(); | |
| 26 | − }, | |
| 27 | − ); | |
| 22 | + await step("Stub the two Google Photos registry products", async () => { | |
| 23 | + await page.route("https://integrations.sh/api/search*", (route) => | |
| 24 | + route.fulfill({ | |
| 25 | + contentType: "application/json", | |
| 26 | + headers: { "access-control-allow-origin": "*" }, | |
| 27 | + json: { | |
| 28 | + results: [ | |
| 29 | + { | |
| 30 | + domain: "photos.google.com", | |
| 31 | + name: "Google Photos Library", | |
| 32 | + description: "Create and manage app-created photos.", | |
| 33 | + kinds: ["openapi"], | |
| 34 | + url: "https://integrations.sh/photos.google.com/", | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + domain: "photos.google.com", | |
| 38 | + name: "Google Photos Picker", | |
| 39 | + description: "Let people select photos for an app.", | |
| 40 | + kinds: ["openapi"], | |
| 41 | + url: "https://integrations.sh/photos.google.com/", | |
| 42 | + }, | |
| 43 | + ], | |
| 44 | + }, | |
| 45 | + }), | |
| 46 | + ); | |
| 47 | + await page.route("https://integrations.sh/api/photos.google.com/surface", (route) => | |
| 48 | + route.fulfill({ | |
| 49 | + contentType: "application/json", | |
| 50 | + headers: { "access-control-allow-origin": "*" }, | |
| 51 | + json: { | |
| 52 | + version: 3, | |
| 53 | + domain: "photos.google.com", | |
| 54 | + surfaces: [ | |
| 55 | + { type: "http", slug: "google-photos-library", spec: librarySpecUrl }, | |
| 56 | + { type: "http", slug: "google-photos-picker", spec: pickerSpecUrl }, | |
| 57 | + ], | |
| 58 | + }, | |
| 59 | + }), | |
| 60 | + ); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + await step("Search for Photos and see two separate product cards", async () => { | |
| 64 | + await visit(page, "/integrations/browse"); | |
| 65 | + await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("photos"); | |
| 66 | + const cards = page.getByTestId("catalog-photos.google.com-openapi"); | |
| 67 | + await cards.filter({ hasText: "Google Photos Library API" }).waitFor(); | |
| 68 | + await cards.filter({ hasText: "Google Photos Picker API" }).waitFor(); | |
| 69 | + }); | |
| 28 | 70 | |
| 29 | − await step("Open the Google Photos Library add flow", async () => { | |
| 30 | − const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | |
| 31 | − await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).click(); | |
| 32 | − await page.waitForURL(/\/integrations\/add\/openapi/); | |
| 33 | − await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | |
| 71 | + await step("Add Photos Library in place; the Picker card is untouched", async () => { | |
| 72 | + await page | |
| 73 | + .getByTestId("catalog-photos.google.com-openapi") | |
| 74 | + .filter({ hasText: "Google Photos Library API" }) | |
| 75 | + .getByRole("button", { name: "Add Google Photos Library API" }) | |
| 76 | + .click(); | |
| 77 | + const libraryCard = page | |
| 78 | + .getByTestId("catalog-photos.google.com-openapi") | |
| 79 | + .filter({ hasText: "Google Photos Library API" }); | |
| 80 | + await libraryCard | |
| 81 | + .getByRole("link", { name: "View Google Photos Library API" }) | |
| 82 | + .waitFor({ timeout: 120_000 }); | |
| 83 | + expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/); | |
| 84 | + // Product identity survived: the LIBRARY slug became the namespace… | |
| 85 | + const href = await libraryCard | |
| 86 | + .getByRole("link", { name: "View Google Photos Library API" }) | |
| 87 | + .getAttribute("href"); | |
| 88 | + expect(href).toContain("/integrations/google_photos_library"); | |
| 89 | + // …and the Picker card, same domain and kind, still offers Add. | |
| 90 | + await page | |
| 91 | + .getByTestId("catalog-photos.google.com-openapi") | |
| 92 | + .filter({ hasText: "Google Photos Picker API" }) | |
| 93 | + .getByRole("button", { name: "Add Google Photos Picker API" }) | |
| 94 | + .waitFor(); | |
| 34 | 95 | }); |
| 35 | 96 | |
| 36 | − await step("The Library link carries the focused OpenAPI preset", async () => { | |
| 37 | − const url = new URL(page.url()); | |
| 38 | − expect(url.searchParams.get("preset")).toBe("google-photos-library"); | |
| 39 | − expect(url.searchParams.get("url")).toContain("photoslibrary"); | |
| 40 | − await expect.poll(() => page.locator("textarea").inputValue()).toContain("photoslibrary"); | |
| 97 | + await step("Adding the SECOND same-kind card keeps its own identity", async () => { | |
| 98 | + // The regression this guards: selection re-derived the surface by | |
| 99 | + // KIND, so the second product of a kind inherited the first's slug, | |
| 100 | + // auth, and overrides. | |
| 101 | + await page | |
| 102 | + .getByTestId("catalog-photos.google.com-openapi") | |
| 103 | + .filter({ hasText: "Google Photos Picker API" }) | |
| 104 | + .getByRole("button", { name: "Add Google Photos Picker API" }) | |
| 105 | + .click(); | |
| 106 | + const pickerCard = page | |
| 107 | + .getByTestId("catalog-photos.google.com-openapi") | |
| 108 | + .filter({ hasText: "Google Photos Picker API" }); | |
| 109 | + await pickerCard | |
| 110 | + .getByRole("link", { name: "View Google Photos Picker API" }) | |
| 111 | + .waitFor({ timeout: 120_000 }); | |
| 112 | + const href = await pickerCard | |
| 113 | + .getByRole("link", { name: "View Google Photos Picker API" }) | |
| 114 | + .getAttribute("href"); | |
| 115 | + expect(href).toContain("/integrations/google_photos_picker"); | |
| 41 | 116 | }); |
| 42 | 117 | }); |
| 43 | 118 | }), |
| @@ -1,77 +1,116 @@ | ||
| 1 | +import { randomBytes } from "node:crypto"; | |
| 2 | + | |
| 1 | 3 | import { expect } from "@effect/vitest"; |
| 2 | 4 | import { Effect } from "effect"; |
| 5 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 3 | 6 | |
| 4 | 7 | import { scenario } from "../src/scenario"; |
| 5 | 8 | import { Browser, Target } from "../src/services"; |
| 6 | −import { clickToReveal, visit } from "../src/surfaces/browser"; | |
| 9 | +import { visit } from "../src/surfaces/browser"; | |
| 7 | 10 | |
| 8 | −// The connect dialog's long-tail search goes to the public integrations.sh | |
| 9 | −// registry from the browser. CI must not depend on the live service, so both | |
| 10 | −// registry endpoints are fulfilled at the network layer here — including the | |
| 11 | −// CORS header a real cross-origin browser call needs. | |
| 11 | +// The picker's registry search goes to the public integrations.sh service from | |
| 12 | +// the browser. CI must not depend on the live service, so the search endpoint | |
| 13 | +// is fulfilled at the network layer here — including the CORS header a real | |
| 14 | +// cross-origin browser call needs. The MCP endpoints themselves are NOT | |
| 15 | +// stubbed: the quick add probes and registers server-side, so the working row | |
| 16 | +// points at a local fixture server and the broken row at a reserved-TLD host. | |
| 12 | 17 | scenario( |
| 13 | − "Connect dialog: integrations.sh catalog search resolves into a prefilled add flow", | |
| 18 | + "Add page: a registry row adds in place, and falls back to the config screen", | |
| 14 | 19 | {}, |
| 15 | − Effect.gen(function* () { | |
| 16 | − const target = yield* Target; | |
| 17 | − const browser = yield* Browser; | |
| 18 | − const identity = yield* target.newIdentity(); | |
| 20 | + Effect.scoped( | |
| 21 | + Effect.gen(function* () { | |
| 22 | + const target = yield* Target; | |
| 23 | + const browser = yield* Browser; | |
| 24 | + // Unique per run — the derived namespace must not collide on targets | |
| 25 | + // whose identities share one tenant (selfhost admin). | |
| 26 | + const suffix = randomBytes(3).toString("hex"); | |
| 27 | + const server = yield* serveMcpServer(() => | |
| 28 | + makeGreetingMcpServer({ name: `catalog-quick-${suffix}` }), | |
| 29 | + ); | |
| 30 | + const identity = yield* target.newIdentity(); | |
| 19 | 31 | |
| 20 | − yield* browser.session(identity, async ({ page, step }) => { | |
| 21 | − await step("Stub the integrations.sh registry endpoints", async () => { | |
| 22 | − await page.route("https://integrations.sh/api/search*", (route) => | |
| 23 | − route.fulfill({ | |
| 24 | − contentType: "application/json", | |
| 25 | − headers: { "access-control-allow-origin": "*" }, | |
| 26 | − json: { | |
| 27 | − results: [ | |
| 28 | − { | |
| 29 | − domain: "todoist.com", | |
| 30 | − name: "todoist.com", | |
| 31 | − description: "Tasks, projects, and collaboration.", | |
| 32 | − kinds: ["mcp", "cli"], | |
| 33 | − url: "https://integrations.sh/todoist.com/", | |
| 34 | − }, | |
| 35 | − ], | |
| 36 | − }, | |
| 37 | − }), | |
| 38 | − ); | |
| 39 | − await page.route("https://integrations.sh/api/todoist.com/surface", (route) => | |
| 40 | − route.fulfill({ | |
| 41 | − contentType: "application/json", | |
| 42 | − headers: { "access-control-allow-origin": "*" }, | |
| 43 | − json: { | |
| 44 | − version: 3, | |
| 45 | − domain: "todoist.com", | |
| 46 | − surfaces: [ | |
| 47 | − { type: "mcp", url: "https://ai.todoist.net/mcp", slug: "todoist" }, | |
| 48 | − { type: "cli", slug: "todoist-cli" }, | |
| 49 | − ], | |
| 50 | − }, | |
| 51 | − }), | |
| 52 | − ); | |
| 53 | − }); | |
| 32 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 33 | + await step("Stub the integrations.sh search endpoint", async () => { | |
| 34 | + await page.route("https://integrations.sh/api/search*", (route) => | |
| 35 | + route.fulfill({ | |
| 36 | + contentType: "application/json", | |
| 37 | + headers: { "access-control-allow-origin": "*" }, | |
| 38 | + json: { | |
| 39 | + results: [ | |
| 40 | + { | |
| 41 | + domain: "todoist.com", | |
| 42 | + name: "todoist.com", | |
| 43 | + description: "Tasks, projects, and collaboration.", | |
| 44 | + kinds: ["mcp", "cli"], | |
| 45 | + surfaces: [ | |
| 46 | + { kind: "mcp", slug: `todoist-${suffix}`, url: server.endpoint }, | |
| 47 | + { kind: "cli", slug: "todoist-cli" }, | |
| 48 | + ], | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + domain: "deadserver.com", | |
| 52 | + name: "deadserver.com", | |
| 53 | + description: "A vendor whose MCP endpoint is gone.", | |
| 54 | + kinds: ["mcp"], | |
| 55 | + surfaces: [ | |
| 56 | + { kind: "mcp", slug: "deadserver", url: "https://mcp.notareal.invalid/mcp" }, | |
| 57 | + ], | |
| 58 | + }, | |
| 59 | + ], | |
| 60 | + }, | |
| 61 | + }), | |
| 62 | + ); | |
| 63 | + }); | |
| 54 | 64 | |
| 55 | − await step("Searching surfaces the catalog row under the presets", async () => { | |
| 56 | − await visit(page, "/integrations"); | |
| 57 | − const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | |
| 58 | − await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); | |
| 59 | − await dialog.getByPlaceholder(/Search or paste a URL/).fill("todoist"); | |
| 60 | − // The CLI-only surface is not offered; the connectable kind is. | |
| 61 | − await dialog | |
| 62 | − .getByRole("button", { name: /todoist\.com/ }) | |
| 63 | − .getByText("MCP") | |
| 64 | − .waitFor(); | |
| 65 | − }); | |
| 65 | + await step("Searching surfaces the connectable rows, not the CLI", async () => { | |
| 66 | + await visit(page, "/integrations/browse"); | |
| 67 | + await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist"); | |
| 68 | + await page.getByTestId(`catalog-todoist-${suffix}`).getByText("Todoist MCP").waitFor(); | |
| 69 | + await page.getByTestId("catalog-deadserver").waitFor(); | |
| 70 | + expect(await page.getByTestId("catalog-todoist-cli").count()).toBe(0); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + await step("Add registers in place; the card flips to View", async () => { | |
| 74 | + await page | |
| 75 | + .getByTestId(`catalog-todoist-${suffix}`) | |
| 76 | + .getByRole("button", { name: "Add Todoist MCP" }) | |
| 77 | + .click(); | |
| 78 | + // Generous timeout: the quick add probes the fixture server and | |
| 79 | + // registers, all behind one click, and the dev server can queue | |
| 80 | + // under CI load. | |
| 81 | + await page | |
| 82 | + .getByTestId(`catalog-todoist-${suffix}`) | |
| 83 | + .getByRole("link", { name: "View Todoist MCP" }) | |
| 84 | + .waitFor({ timeout: 90_000 }); | |
| 85 | + // The whole point: the user never left the picker. | |
| 86 | + expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + await step("View jumps to the integration's hub", async () => { | |
| 90 | + await page | |
| 91 | + .getByTestId(`catalog-todoist-${suffix}`) | |
| 92 | + .getByRole("link", { name: "View Todoist MCP" }) | |
| 93 | + .click(); | |
| 94 | + await page.waitForURL(new RegExp(`/integrations/todoist_${suffix}`)); | |
| 95 | + await page.goBack(); | |
| 96 | + await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist"); | |
| 97 | + await page.getByTestId("catalog-deadserver").waitFor(); | |
| 98 | + }); | |
| 66 | 99 | |
| 67 | − await step("Picking the row lands on the MCP add flow, prefilled", async () => { | |
| 68 | − const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | |
| 69 | − await dialog.getByRole("button", { name: /todoist\.com/ }).click(); | |
| 70 | − await page.waitForURL(/\/integrations\/add\/mcp/); | |
| 71 | − const url = new URL(page.url()); | |
| 72 | − expect(url.searchParams.get("url")).toBe("https://ai.todoist.net/mcp"); | |
| 73 | − expect(url.searchParams.get("namespace")).toBe("todoist"); | |
| 100 | + await step("An unreachable endpoint falls back to the config screen", async () => { | |
| 101 | + await page | |
| 102 | + .getByTestId("catalog-deadserver") | |
| 103 | + .getByRole("button", { name: "Add Deadserver MCP" }) | |
| 104 | + .click(); | |
| 105 | + // Quick add cannot register it, so the click lands on the add flow | |
| 106 | + // prefilled with the same facts — where the probe failure renders | |
| 107 | + // with retry UX. | |
| 108 | + await page.waitForURL(/\/integrations\/add\/mcp/, { timeout: 90_000 }); | |
| 109 | + const url = new URL(page.url()); | |
| 110 | + expect(url.searchParams.get("url")).toBe("https://mcp.notareal.invalid/mcp"); | |
| 111 | + expect(url.searchParams.get("namespace")).toBe("deadserver"); | |
| 112 | + }); | |
| 74 | 113 | }); |
| 75 | − }); | |
| 76 | − }), | |
| 114 | + }), | |
| 115 | + ), | |
| 77 | 116 | ); |
| @@ -40,7 +40,7 @@ scenario( · 39 unmodified lines | ||
| 40 | 40 | // The URL auto-probes (debounced); the method list appears once the |
| 41 | 41 | // probe lands — an open server seeds the detected no-auth method. |
| 42 | 42 | await page.getByText("How does this server authenticate?").waitFor(); |
| 43 | − await page.getByText("Method 1 · Detected").waitFor(); | |
| 43 | + await page.getByText("No authentication · Detected").waitFor(); | |
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | 46 | await step("Add the integration", async () => { |
| @@ -3,10 +3,16 @@ import { Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { scenario } from "../src/scenario"; |
| 5 | 5 | import { Browser, Target } from "../src/services"; |
| 6 | −import { clickToReveal, visit } from "../src/surfaces/browser"; | |
| 6 | +import { visit } from "../src/surfaces/browser"; | |
| 7 | 7 | |
| 8 | +const gmailSpecUrl = "https://integrations.sh/specs/google/google-gmail.json"; | |
| 9 | +const outlookSpecUrl = "https://integrations.sh/specs/microsoft-graph/mail.json"; | |
| 10 | + | |
| 11 | +// Provider products are ordinary integrations.sh results now. Stub both the | |
| 12 | +// search response and the per-domain surface documents so this scenario proves | |
| 13 | +// the full registry-card-to-add-flow journey without depending on the service. | |
| 8 | 14 | scenario( |
| 9 | − "Provider catalog · Google and Microsoft services are OpenAPI presets", | |
| 15 | + "Google and Microsoft products are registry cards that resolve into prefilled add flows", | |
| 10 | 16 | {}, |
| 11 | 17 | Effect.gen(function* () { |
| 12 | 18 | const target = yield* Target; |
| @@ -14,58 +20,91 @@ scenario( · 1 unmodified line | ||
| 14 | 20 | const identity = yield* target.newIdentity(); |
| 15 | 21 | |
| 16 | 22 | yield* browser.session(identity, async ({ page, step }) => { |
| 17 | − await step("Open the integrations picker from the sidebar", async () => { | |
| 18 | − await visit(page, "/policies"); | |
| 19 | − await clickToReveal( | |
| 20 | − page | |
| 21 | − .getByRole("navigation") | |
| 22 | − .getByRole("button", { name: "Browse integrations", exact: true }), | |
| 23 | − page.getByRole("dialog", { name: "Connect an integration" }), | |
| 23 | + await step("Stub the Google and Microsoft registry products", async () => { | |
| 24 | + await page.route("https://integrations.sh/api/search*", (route) => | |
| 25 | + route.fulfill({ | |
| 26 | + contentType: "application/json", | |
| 27 | + headers: { "access-control-allow-origin": "*" }, | |
| 28 | + json: { | |
| 29 | + results: [ | |
| 30 | + { | |
| 31 | + domain: "gmail.com", | |
| 32 | + name: "Gmail", | |
| 33 | + description: "Email from Google.", | |
| 34 | + kinds: ["openapi"], | |
| 35 | + url: "https://integrations.sh/gmail.com/", | |
| 36 | + }, | |
| 37 | + { | |
| 38 | + domain: "graph.microsoft.com", | |
| 39 | + name: "Outlook Mail", | |
| 40 | + description: "Email from Microsoft.", | |
| 41 | + kinds: ["openapi"], | |
| 42 | + url: "https://integrations.sh/graph.microsoft.com/", | |
| 43 | + }, | |
| 44 | + ], | |
| 45 | + }, | |
| 46 | + }), | |
| 47 | + ); | |
| 48 | + await page.route("https://integrations.sh/api/gmail.com/surface", (route) => | |
| 49 | + route.fulfill({ | |
| 50 | + contentType: "application/json", | |
| 51 | + headers: { "access-control-allow-origin": "*" }, | |
| 52 | + json: { | |
| 53 | + version: 3, | |
| 54 | + domain: "gmail.com", | |
| 55 | + surfaces: [{ type: "http", slug: "google-gmail", spec: gmailSpecUrl }], | |
| 56 | + }, | |
| 57 | + }), | |
| 58 | + ); | |
| 59 | + await page.route("https://integrations.sh/api/graph.microsoft.com/surface", (route) => | |
| 60 | + route.fulfill({ | |
| 61 | + contentType: "application/json", | |
| 62 | + headers: { "access-control-allow-origin": "*" }, | |
| 63 | + json: { | |
| 64 | + version: 3, | |
| 65 | + domain: "graph.microsoft.com", | |
| 66 | + surfaces: [{ type: "http", slug: "outlook-mail", spec: outlookSpecUrl }], | |
| 67 | + }, | |
| 68 | + }), | |
| 24 | 69 | ); |
| 25 | 70 | }); |
| 26 | 71 | |
| 27 | − await step("The picker exposes OpenAPI plus provider service presets", async () => { | |
| 28 | − const dialog = page.getByRole("dialog", { | |
| 29 | − name: "Connect an integration", | |
| 30 | − }); | |
| 31 | − const search = dialog.getByPlaceholder(/Search or paste a URL/); | |
| 32 | − await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor(); | |
| 33 | − | |
| 72 | + await step("Search for Gmail and see its registry card and domain", async () => { | |
| 73 | + await visit(page, "/integrations/browse"); | |
| 74 | + const search = page.getByPlaceholder(/Search integrations, or paste a URL/); | |
| 34 | 75 | await search.fill("gmail"); |
| 35 | − await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor(); | |
| 36 | − | |
| 37 | − await search.fill("onedrive"); | |
| 38 | − await dialog.getByRole("link", { name: /^OneDrive Files\b/ }).waitFor(); | |
| 76 | + const card = page.getByTestId("catalog-gmail.com-openapi"); | |
| 77 | + await card.waitFor(); | |
| 78 | + await card.getByText("gmail.com").waitFor(); | |
| 39 | 79 | }); |
| 40 | 80 | |
| 41 | − await step("OpenAPI add remains generic", async () => { | |
| 42 | − await page.goto("/integrations/add/openapi", { | |
| 43 | − waitUntil: "domcontentloaded", | |
| 44 | − }); | |
| 45 | − await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | |
| 46 | − await page.getByText("OpenAPI Spec").waitFor(); | |
| 47 | − expect(await page.getByText("Customize your Google connection").count()).toBe(0); | |
| 48 | − expect(await page.getByText("Customize Microsoft Graph").count()).toBe(0); | |
| 81 | + await step("Add Gmail in place: the card flips to View", async () => { | |
| 82 | + await page | |
| 83 | + .getByTestId("catalog-gmail.com-openapi") | |
| 84 | + .getByRole("button", { name: "Add Gmail API" }) | |
| 85 | + .click(); | |
| 86 | + // The quick add fetches the hosted spec server-side and registers — | |
| 87 | + // no navigation. Generous timeout: a real spec fetch + parse rides | |
| 88 | + // behind the click. | |
| 89 | + await page | |
| 90 | + .getByTestId("catalog-gmail.com-openapi") | |
| 91 | + .getByRole("link", { name: "View Gmail API" }) | |
| 92 | + .waitFor({ timeout: 120_000 }); | |
| 93 | + expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/); | |
| 94 | + // The registry surface slug became the namespace. | |
| 95 | + const href = await page | |
| 96 | + .getByTestId("catalog-gmail.com-openapi") | |
| 97 | + .getByRole("link", { name: "View Gmail API" }) | |
| 98 | + .getAttribute("href"); | |
| 99 | + expect(href).toContain("/integrations/google_gmail"); | |
| 49 | 100 | }); |
| 50 | 101 | |
| 51 | − await step("A Google service preset opens the OpenAPI add flow", async () => { | |
| 52 | − await page.goto( | |
| 53 | − "/integrations/add/openapi?preset=google-gmail&url=https%3A%2F%2Fwww.googleapis.com%2Fdiscovery%2Fv1%2Fapis%2Fgmail%2Fv1%2Frest", | |
| 54 | − { waitUntil: "domcontentloaded" }, | |
| 55 | − ); | |
| 56 | − await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | |
| 57 | − await expect.poll(() => page.locator("textarea").inputValue()).toContain("gmail"); | |
| 58 | − }); | |
| 59 | − | |
| 60 | − await step("A Microsoft service preset opens the OpenAPI add flow", async () => { | |
| 61 | − await page.goto( | |
| 62 | − "/integrations/add/openapi?preset=microsoft-files&url=https%3A%2F%2Fgithub.com%2FUsefulSoftwareCo%2Fexecutor%2Freleases%2Fdownload%2Fgraph-slices%2Ffiles.yaml", | |
| 63 | − { waitUntil: "domcontentloaded" }, | |
| 64 | − ); | |
| 65 | − await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | |
| 66 | − await expect | |
| 67 | − .poll(() => page.locator("textarea").inputValue()) | |
| 68 | − .toContain("graph-slices/files.yaml"); | |
| 102 | + await step("Search for Outlook and see its separate registry card and domain", async () => { | |
| 103 | + await visit(page, "/integrations/browse"); | |
| 104 | + await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("outlook"); | |
| 105 | + const card = page.getByTestId("catalog-graph.microsoft.com-openapi"); | |
| 106 | + await card.waitFor(); | |
| 107 | + await card.getByText("graph.microsoft.com").waitFor(); | |
| 69 | 108 | }); |
| 70 | 109 | }); |
| 71 | 110 | }), |
| @@ -169,7 +169,7 @@ scenario( · 168 unmodified lines | ||
| 169 | 169 | }); |
| 170 | 170 | |
| 171 | 171 | await step("The probe detected OAuth", async () => { |
| 172 | − await page.getByText("Method 1 · Detected").waitFor(); | |
| 172 | + await page.getByText("OAuth · Detected").waitFor(); | |
| 173 | 173 | // The OAuth editor declares discovery-at-connect, not pasted URLs. |
| 174 | 174 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); |
| 175 | 175 | }); |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | // Selfhost-only (browser): a spec/probe-DETECTED auth method is immutable in |
| 2 | 2 | // the add flow. The shared AuthMethodListEditor renders detected methods as a |
| 3 | −// disabled, read-only summary ("Pulled from spec. Remove to override.") with no | |
| 3 | +// disabled, read-only summary (declared-by-this-API footer) with no | |
| 4 | 4 | // kind selector, so a user can't silently retype the spec's method into a kind |
| 5 | 5 | // nothing backs. A method the user adds by hand stays fully editable. Both the |
| 6 | 6 | // MCP and OpenAPI add flows compose the same editor, so one behavior, two |
| @@ -18,7 +18,9 @@ import { scenario } from "../src/scenario"; · 11 unmodified lines | ||
| 18 | 18 | import { Browser, Target } from "../src/services"; |
| 19 | 19 | import { visit } from "../src/surfaces/browser"; |
| 20 | 20 | |
| 21 | −const REMOVE_HINT = "Pulled from spec. Remove to override."; | |
| 21 | +// The detected-method footer names the source and what happens next; the | |
| 22 | +// stable common prefix is the assertion target. | |
| 23 | +const REMOVE_HINT = "Declared by this API."; | |
| 22 | 24 | |
| 23 | 25 | scenario( |
| 24 | 26 | "Detected auth · an MCP probe's OAuth method is immutable in the add flow", |
| @@ -39,20 +41,16 @@ scenario( · 14 unmodified lines | ||
| 39 | 41 | await step("Open the add-MCP flow pointed at the OAuth server", async () => { |
| 40 | 42 | await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); |
| 41 | 43 | await page.getByText("How does this server authenticate?").waitFor(); |
| 42 | − await page.getByText("Method 1 · Detected").waitFor(); | |
| 44 | + await page.getByText("OAuth · Detected").waitFor(); | |
| 43 | 45 | }); |
| 44 | 46 | |
| 45 | 47 | await step("The detected method is locked: read-only, named, no selector", async () => { |
| 46 | − // The kind is named explicitly ("OAuth"), the discovered-OAuth summary | |
| 47 | − // and override hint sit inside a disabled block, and there is NO | |
| 48 | − // editable kind selector (the FilterTabs render as buttons). | |
| 49 | − await page.getByText("OAuth", { exact: true }).first().waitFor(); | |
| 48 | + // The kind is named in the row TITLE ("OAuth · Detected", asserted | |
| 49 | + // above), the discovered-OAuth summary and override hint render | |
| 50 | + // read-only, and there is NO editable kind selector (the FilterTabs | |
| 51 | + // render as buttons). | |
| 50 | 52 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); |
| 51 | 53 | await page.getByText(REMOVE_HINT).waitFor(); |
| 52 | − expect( | |
| 53 | − await page.locator("[aria-disabled]").count(), | |
| 54 | − "the detected method renders a disabled (non-interactive) block", | |
| 55 | − ).toBeGreaterThan(0); | |
| 56 | 54 | expect( |
| 57 | 55 | await page.getByRole("button", { name: "API key", exact: true }).count(), |
| 58 | 56 | "no editable kind selector is shown for the detected method", |
| @@ -150,7 +148,9 @@ scenario( · 91 unmodified lines | ||
| 150 | 148 | .first() |
| 151 | 149 | .fill(spec.url); |
| 152 | 150 | await page.getByText("How does this API authenticate?").waitFor(); |
| 153 | − await page.getByText("Method 2").waitFor(); | |
| 151 | + // Detected rows are titled by their kind now, not "Method N"; the | |
| 152 | + // hint below is what marks a rendered detected method. | |
| 153 | + await page.getByText(REMOVE_HINT).first().waitFor(); | |
| 154 | 154 | }); |
| 155 | 155 | |
| 156 | 156 | await step("Both detected methods are locked, named, read-only", async () => { |
| @@ -162,8 +162,9 @@ scenario( · 5 unmodified lines | ||
| 162 | 162 | "both detected methods show the remove-to-override hint", |
| 163 | 163 | ).toBe(2); |
| 164 | 164 | await page.getByText("https://api.acme.test/oauth/authorize").waitFor(); |
| 165 | − await page.getByText("API key", { exact: true }).first().waitFor(); | |
| 166 | − await page.getByText("OAuth", { exact: true }).first().waitFor(); | |
| 165 | + // Kinds are named in the row TITLES ("API key · …" / "OAuth · …"). | |
| 166 | + await page.getByText("API key · ").first().waitFor(); | |
| 167 | + await page.getByText("OAuth · ").first().waitFor(); | |
| 167 | 168 | expect( |
| 168 | 169 | await page.getByRole("button", { name: "OAuth", exact: true }).count(), |
| 169 | 170 | "no editable kind selector is shown for the detected methods", |
| @@ -61,7 +61,7 @@ scenario( · 60 unmodified lines | ||
| 61 | 61 | }); |
| 62 | 62 | |
| 63 | 63 | await step("The probe seeded a detected Bearer-header method", async () => { |
| 64 | − await page.getByText("Method 1 · Detected").waitFor(); | |
| 64 | + await page.getByText("API key · Detected").waitFor(); | |
| 65 | 65 | // The preview card flags the gate rather than failing the probe. |
| 66 | 66 | await page.getByText("Auth required").first().waitFor(); |
| 67 | 67 | }); |
| @@ -51,7 +51,7 @@ scenario( · 50 unmodified lines | ||
| 51 | 51 | addUrl.searchParams.set("url", server.endpoint); |
| 52 | 52 | await visit(page, addUrl.toString()); |
| 53 | 53 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); |
| 54 | − await page.getByText("Method 1 · Detected").waitFor(); | |
| 54 | + await page.getByText("OAuth · Detected").waitFor(); | |
| 55 | 55 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); |
| 56 | 56 | }); |
| 57 | 57 | |
| @@ -24,6 +24,7 @@ · 23 unmodified lines | ||
| 24 | 24 | "packages/*/*", |
| 25 | 25 | "packages/react", |
| 26 | 26 | "packages/app", |
| 27 | + "packages/onboarding-demo", | |
| 27 | 28 | "apps/*", |
| 28 | 29 | "examples/*", |
| 29 | 30 | "e2e" |
| @@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './ · 16 unmodified lines | ||
| 17 | 17 | import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts' |
| 18 | 18 | import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' |
| 19 | 19 | import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' |
| 20 | +import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../react/src/routes/integrations.browse' | |
| 20 | 21 | import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' |
| 21 | 22 | import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug' |
| 22 | 23 | import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId' |
| @@ -70,6 +71,12 @@ const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute = · 47 unmodified lines | ||
| 70 | 71 | path: '/{-$orgSlug}/resume/$executionId', |
| 71 | 72 | getParentRoute: () => rootRouteImport, |
| 72 | 73 | } as any) |
| 74 | +const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute = | |
| 75 | + DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport.update({ | |
| 76 | + id: '/{-$orgSlug}/integrations/browse', | |
| 77 | + path: '/{-$orgSlug}/integrations/browse', | |
| 78 | + getParentRoute: () => rootRouteImport, | |
| 79 | + } as any) | |
| 73 | 80 | const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute = |
| 74 | 81 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport.update({ |
| 75 | 82 | id: '/{-$orgSlug}/integrations/$namespace', |
| @@ -113,6 +120,7 @@ export interface FileRoutesByFullPath { · 37 unmodified lines | ||
| 113 | 120 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute |
| 114 | 121 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute |
| 115 | 122 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 123 | + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute | |
| 116 | 124 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 117 | 125 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 118 | 126 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -128,6 +136,7 @@ export interface FileRoutesByTo { · 9 unmodified lines | ||
| 128 | 136 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute |
| 129 | 137 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute |
| 130 | 138 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 139 | + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute | |
| 131 | 140 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 132 | 141 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 133 | 142 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -144,6 +153,7 @@ export interface FileRoutesById { · 10 unmodified lines | ||
| 144 | 153 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute |
| 145 | 154 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute |
| 146 | 155 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 156 | + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute | |
| 147 | 157 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 148 | 158 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 149 | 159 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -161,6 +171,7 @@ export interface FileRouteTypes { · 11 unmodified lines | ||
| 161 | 171 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 162 | 172 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 163 | 173 | | '/{-$orgSlug}/integrations/$namespace' |
| 174 | + | '/{-$orgSlug}/integrations/browse' | |
| 164 | 175 | | '/{-$orgSlug}/resume/$executionId' |
| 165 | 176 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 166 | 177 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -176,6 +187,7 @@ export interface FileRouteTypes { · 9 unmodified lines | ||
| 176 | 187 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 177 | 188 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 178 | 189 | | '/{-$orgSlug}/integrations/$namespace' |
| 190 | + | '/{-$orgSlug}/integrations/browse' | |
| 179 | 191 | | '/{-$orgSlug}/resume/$executionId' |
| 180 | 192 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 181 | 193 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -191,6 +203,7 @@ export interface FileRouteTypes { · 9 unmodified lines | ||
| 191 | 203 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 192 | 204 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 193 | 205 | | '/{-$orgSlug}/integrations/$namespace' |
| 206 | + | '/{-$orgSlug}/integrations/browse' | |
| 194 | 207 | | '/{-$orgSlug}/resume/$executionId' |
| 195 | 208 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 196 | 209 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -206,6 +219,7 @@ export interface RootRouteChildren { · 9 unmodified lines | ||
| 206 | 219 | DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 207 | 220 | DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute |
| 208 | 221 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 222 | + DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute | |
| 209 | 223 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 210 | 224 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 211 | 225 | DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| @@ -269,6 +283,13 @@ declare module '@tanstack/react-router' { · 57 unmodified lines | ||
| 269 | 283 | preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport |
| 270 | 284 | parentRoute: typeof rootRouteImport |
| 271 | 285 | } |
| 286 | + '/{-$orgSlug}/integrations/browse': { | |
| 287 | + id: '/{-$orgSlug}/integrations/browse' | |
| 288 | + path: '/{-$orgSlug}/integrations/browse' | |
| 289 | + fullPath: '/{-$orgSlug}/integrations/browse' | |
| 290 | + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport | |
| 291 | + parentRoute: typeof rootRouteImport | |
| 292 | + } | |
| 272 | 293 | '/{-$orgSlug}/integrations/$namespace': { |
| 273 | 294 | id: '/{-$orgSlug}/integrations/$namespace' |
| 274 | 295 | path: '/{-$orgSlug}/integrations/$namespace' |
| @@ -353,6 +374,8 @@ const rootRouteChildren: RootRouteChildren = { · 78 unmodified lines | ||
| 353 | 374 | DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute, |
| 354 | 375 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 355 | 376 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 377 | + DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute: | |
| 378 | + DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute, | |
| 356 | 379 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: |
| 357 | 380 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute, |
| 358 | 381 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: |
| @@ -136,6 +136,12 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { · 135 unmodified lines | ||
| 136 | 136 | : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", |
| 137 | 137 | ].join(" ")} |
| 138 | 138 | > |
| 139 | + {/* Same inputs as the integrations list. Passing only the | |
| 140 | + slug and kind meant an icon could be found ONLY when a | |
| 141 | + bundled preset happened to match — anything added from the | |
| 142 | + registry has a slug no preset knows, so the sidebar sat | |
| 143 | + blank while the same integration showed its mark in the | |
| 144 | + list. The display URL is what a favicon is derived from. */} | |
| 139 | 145 | <IntegrationIconWithAccount |
| 140 | 146 | icon={integrationPresetIconUrl( |
| 141 | 147 | { id: slug, kind: integration.kind, name, url: integration.displayUrl }, |
| @@ -124,6 +124,12 @@ export interface IntegrationPreset { · 123 unmodified lines | ||
| 124 | 124 | readonly specOverrides?: readonly unknown[]; |
| 125 | 125 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; |
| 126 | 126 | readonly healthCheck?: HealthCheckSpec; |
| 127 | + /** The public registry lists this product, so the picker shows the | |
| 128 | + * registry's card instead of a preset card (the preset's knowledge still | |
| 129 | + * rides quick add). Built-in presets set this; a deployment's custom | |
| 130 | + * preset does not, and keeps its own card — hiding it would make a | |
| 131 | + * private API undiscoverable. */ | |
| 132 | + readonly registryListed?: boolean; | |
| 127 | 133 | } |
| 128 | 134 | |
| 129 | 135 | export type IntegrationPresetAuthentication = |
| @@ -199,6 +205,34 @@ export interface EditSheetSectionProps { · 69 unmodified lines | ||
| 199 | 205 | readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void; |
| 200 | 206 | } |
| 201 | 207 | |
| 208 | +/** What a registry row already knows about a connect target — enough, for | |
| 209 | + * some plugins, to register without showing the configuration screen. */ | |
| 210 | +export interface IntegrationQuickAddInput { | |
| 211 | + /** The connect target: MCP endpoint, OpenAPI spec URL, or GraphQL endpoint. */ | |
| 212 | + readonly url: string; | |
| 213 | + /** Display name for the integration ("Stripe MCP", "Outlook Mail API"). */ | |
| 214 | + readonly name: string; | |
| 215 | + /** Registry surface slug, used as the namespace seed when present. */ | |
| 216 | + readonly slug?: string; | |
| 217 | + /** The registry PRODUCT's domain (notion.com) — the identity credential | |
| 218 | + * guidance and favicons key on. Often differs from the connect URL's | |
| 219 | + * host: specs live on code hosts. */ | |
| 220 | + readonly domain?: string; | |
| 221 | + /** Registry-declared credential placement, e.g. "Authorization: {api_key}". */ | |
| 222 | + readonly authHeader?: string; | |
| 223 | + /** Registry-declared credential kind ("none", "oauth", "api_key", …). */ | |
| 224 | + readonly authKind?: string; | |
| 225 | + /** RFC 6902 patch the registry says to apply to the fetched spec. */ | |
| 226 | + readonly specOverrides?: readonly unknown[]; | |
| 227 | +} | |
| 228 | + | |
| 229 | +export type IntegrationQuickAddResult = | |
| 230 | + /** Registered; `slug` is the created integration's namespace. */ | |
| 231 | + | { readonly ok: true; readonly slug: string } | |
| 232 | + /** Could not add headlessly — the host falls back to the configuration | |
| 233 | + * screen, which renders the failure with full context. */ | |
| 234 | + | { readonly ok: false; readonly reason: string }; | |
| 235 | + | |
| 202 | 236 | export interface IntegrationPlugin { |
| 203 | 237 | /** Unique key matching the SDK plugin id (e.g. "openapi"). */ |
| 204 | 238 | readonly key: string; |
| @@ -213,6 +247,21 @@ export interface IntegrationPlugin { · 8 unmodified lines | ||
| 213 | 247 | readonly initialUrl?: string; |
| 214 | 248 | readonly initialPreset?: string; |
| 215 | 249 | readonly initialNamespace?: string; |
| 250 | + /** Registry-declared credential placement for the surface, e.g. | |
| 251 | + * "Authorization: {api_key}" — the pattern Linear's no-Bearer personal | |
| 252 | + * keys need. Plugins whose surfaces can't self-describe auth (GraphQL) | |
| 253 | + * seed their auth-method editor from it. */ | |
| 254 | + readonly initialAuthHeader?: string; | |
| 255 | + readonly initialAuthNote?: string; | |
| 256 | + /** Registry-declared credential kind ("none", "oauth", "api_key", …). | |
| 257 | + * Lets a flow whose live probe fails, or whose surface can't be probed, | |
| 258 | + * still declare the right method — an authless MCP server is a fact the | |
| 259 | + * registry already knows. */ | |
| 260 | + readonly initialAuthKind?: string; | |
| 261 | + /** JSON-encoded RFC 6902 patch the registry says to apply to the fetched | |
| 262 | + * spec — the registry's mechanism for improving a vendor's published | |
| 263 | + * document over time without hosting a fork. */ | |
| 264 | + readonly initialSpecOverrides?: string; | |
| 216 | 265 | }>; |
| 217 | 266 | /** Legacy full-page edit surface. No host renders this anymore — plugin |
| 218 | 267 | * configuration lives in the integration Edit sheet via `editSheet`. */ |
| @@ -245,6 +294,17 @@ export interface IntegrationPlugin { · 26 unmodified lines | ||
| 245 | 294 | * Call from the host on intent (hover/focus) so the chunks land before the |
| 246 | 295 | * user navigates into the add page. Idempotent. */ |
| 247 | 296 | readonly preload?: () => void; |
| 297 | + /** Headless one-click add for a registry row whose facts are already known | |
| 298 | + * (URL + auth indicators). A HOOK, not a plain function, because each | |
| 299 | + * plugin binds its own mutation atoms — the host mounts one bridge | |
| 300 | + * component per plugin to collect the bound callbacks. Absent means the | |
| 301 | + * plugin always needs its configuration screen. A `{ok: false}` result is | |
| 302 | + * an expected outcome (unreachable server, slug collision), not an error: | |
| 303 | + * the host falls back to the configuration screen prefilled with the same | |
| 304 | + * facts. */ | |
| 305 | + readonly useQuickAdd?: () => ( | |
| 306 | + input: IntegrationQuickAddInput, | |
| 307 | + ) => Promise<IntegrationQuickAddResult>; | |
| 248 | 308 | } |
| 249 | 309 | |
| 250 | 310 | // --------------------------------------------------------------------------- |
| @@ -612,6 +612,10 @@ export interface IntegrationPreset { · 611 unmodified lines | ||
| 612 | 612 | readonly specOverrides?: readonly unknown[]; |
| 613 | 613 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; |
| 614 | 614 | readonly healthCheck?: HealthCheckSpec; |
| 615 | + /** The public registry lists this product: the picker shows the registry's | |
| 616 | + * card, and the preset's knowledge rides quick add instead. A custom | |
| 617 | + * deployment preset leaves this unset and keeps its own card. */ | |
| 618 | + readonly registryListed?: boolean; | |
| 615 | 619 | readonly transport?: "remote" | "stdio"; |
| 616 | 620 | readonly command?: string; |
| 617 | 621 | readonly args?: readonly string[]; |
| @@ -0,0 +1 @@ | ||
| 1 | +# @executor-js/onboarding-demo | |
| @@ -0,0 +1,53 @@ | ||
| 1 | +# Onboarding demo | |
| 2 | + | |
| 3 | +A standalone reproduction of executor's **current** add-integration flow, from | |
| 4 | +"the CLI is connected" to "the integration has a working connection". No API, no | |
| 5 | +auth, no tenant — it runs off `src/fixtures.ts` and the public integrations.sh | |
| 6 | +endpoints. | |
| 7 | + | |
| 8 | +``` | |
| 9 | +bun run --cwd packages/onboarding-demo dev # http://localhost:5199 | |
| 10 | +``` | |
| 11 | + | |
| 12 | +Every screen is addressable: `#integrations-empty`, `#connect-dialog`, | |
| 13 | +`#add-openapi`, `#detail-accounts`, `#add-account-credential`, | |
| 14 | +`#add-account-place`, `#oauth-stuck`, `#integrations-populated`. `[` and `]` | |
| 15 | +step through them. `src/flow.ts` records, per screen, the route and source | |
| 16 | +component it was reproduced from plus the first-run reactions against it. | |
| 17 | + | |
| 18 | +A floating Current / Reworked control in the bottom-right corner switches | |
| 19 | +between the two flows. | |
| 20 | + | |
| 21 | +## Reworked flow | |
| 22 | + | |
| 23 | +`#reworked` — the picker, `#reworked/installed`, `#reworked/custom`, | |
| 24 | +`#reworked/<domain>`, `#reworked/<domain>/auth`. Browse/Installed carries | |
| 25 | +navigation within the picker; the console sidebar stays as it is today. A deep link to a domain you have not added in this session | |
| 26 | +says so rather than rendering an empty screen — the prototype keeps no state | |
| 27 | +across reloads. | |
| 28 | + | |
| 29 | +Registry picks and custom URLs converge: both become an added integration with | |
| 30 | +one `default` account reading Needs auth. Only the detail header differs, naming | |
| 31 | +where the definition came from. | |
| 32 | + | |
| 33 | +## Fidelity | |
| 34 | + | |
| 35 | +- The curated preset list is imported from the real plugin modules | |
| 36 | + (`@executor-js/plugin-openapi/presets`, `@executor-js/plugin-mcp/presets`), | |
| 37 | + so it is the list the console shows, in the console's order. | |
| 38 | +- UI primitives and design tokens are imported from `@executor-js/react`, not | |
| 39 | + copied — the reproduction uses the same buttons, dialogs, card stacks, tabs | |
| 40 | + and tokens as the console. | |
| 41 | +- The catalog search calls the real `integrations.sh/api/search` with the same | |
| 42 | + 250ms debounce and 2-character minimum. | |
| 43 | +- Screen structure and copy are transcribed from the source components named in | |
| 44 | + the inspector. | |
| 45 | + | |
| 46 | +What is faked: the workspace's own data (integrations, connections, tools) and | |
| 47 | +every mutation. Adding an integration waits and moves on; nothing persists. | |
| 48 | + | |
| 49 | +`fixtures.ts` also exposes two integrations.sh endpoints the console does **not** | |
| 50 | +use today — `fetchCatalogDomains` (the whole ~3.4k-domain popularity-sorted | |
| 51 | +registry) and `fetchSurfaceCredentials` (per-domain credential label, the URL | |
| 52 | +that mints the key, and setup instructions). They are unused by the | |
| 53 | +reproduction and present as raw material for the rework. | |
| @@ -0,0 +1,18 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<html lang="en"> | |
| 3 | + <head> | |
| 4 | + <meta charset="utf-8" /> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 6 | + <title>Executor onboarding — current flow</title> | |
| 7 | + <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| 8 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| 9 | + <link | |
| 10 | + rel="stylesheet" | |
| 11 | + href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap" | |
| 12 | + /> | |
| 13 | + </head> | |
| 14 | + <body> | |
| 15 | + <div id="root"></div> | |
| 16 | + <script type="module" src="/src/main.tsx"></script> | |
| 17 | + </body> | |
| 18 | +</html> | |
| @@ -0,0 +1,32 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@executor-js/onboarding-demo", | |
| 3 | + "version": "0.0.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "scripts": { | |
| 7 | + "dev": "vite", | |
| 8 | + "build": "vite build", | |
| 9 | + "preview": "vite preview", | |
| 10 | + "typecheck": "tsgo --noEmit" | |
| 11 | + }, | |
| 12 | + "dependencies": { | |
| 13 | + "@executor-js/plugin-mcp": "workspace:*", | |
| 14 | + "@executor-js/plugin-openapi": "workspace:*", | |
| 15 | + "@executor-js/react": "workspace:*", | |
| 16 | + "@executor-js/sdk": "workspace:*", | |
| 17 | + "lucide-react": "^1.7.0", | |
| 18 | + "react": "catalog:", | |
| 19 | + "react-dom": "catalog:", | |
| 20 | + "sonner": "^2.0.7", | |
| 21 | + "tldts": "^7.0.28" | |
| 22 | + }, | |
| 23 | + "devDependencies": { | |
| 24 | + "@tailwindcss/vite": "catalog:", | |
| 25 | + "@types/react": "catalog:", | |
| 26 | + "@types/react-dom": "catalog:", | |
| 27 | + "@vitejs/plugin-react": "catalog:", | |
| 28 | + "tailwindcss": "catalog:", | |
| 29 | + "typescript": "catalog:", | |
| 30 | + "vite": "catalog:" | |
| 31 | + } | |
| 32 | +} | |
| @@ -0,0 +1,202 @@ | ||
| 1 | +import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 2 | +import { cn } from "@executor-js/react/lib/utils"; | |
| 3 | +import { Shell } from "./shell"; | |
| 4 | +import { IntegrationsPage } from "./screens/integrations-page"; | |
| 5 | +import { ConnectDialog } from "./screens/connect-dialog"; | |
| 6 | +import { AddOpenApiPage } from "./screens/add-openapi-page"; | |
| 7 | +import { IntegrationDetailPage } from "./screens/integration-detail"; | |
| 8 | +import { AddAccountModal } from "./screens/add-account-modal"; | |
| 9 | +import { flowSteps, type StepId } from "./flow"; | |
| 10 | +import { stepState, type AccountModal, type ScreenView } from "./step-state"; | |
| 11 | +import { NextFlow } from "./next-flow"; | |
| 12 | +import { | |
| 13 | + gmailAuthMethods, | |
| 14 | + gmailIntegration, | |
| 15 | + posthogAuthMethods, | |
| 16 | + posthogIntegration, | |
| 17 | + seededIntegrations, | |
| 18 | + type DemoIntegration, | |
| 19 | + type DemoPreset, | |
| 20 | +} from "./fixtures"; | |
| 21 | + | |
| 22 | +const posthogPreset: DemoPreset = { | |
| 23 | + id: "posthog", | |
| 24 | + name: "PostHog", | |
| 25 | + summary: "Product analytics, events, feature flags, and insights.", | |
| 26 | + icon: "https://integrations.sh/logo/posthog.com", | |
| 27 | + url: "https://raw.githubusercontent.com/PostHog/posthog/master/openapi/openapi.json", | |
| 28 | + pluginKey: "openapi", | |
| 29 | + pluginLabel: "OpenAPI", | |
| 30 | +}; | |
| 31 | + | |
| 32 | +const stepFromHash = (): StepId => { | |
| 33 | + const hash = globalThis.location?.hash.replace(/^#/, "") ?? ""; | |
| 34 | + return flowSteps.some((step) => step.id === hash) ? (hash as StepId) : "integrations-empty"; | |
| 35 | +}; | |
| 36 | + | |
| 37 | +function CurrentFlowApp() { | |
| 38 | + // Each screen is addressable as `#<step-id>`, so a specific screen can be | |
| 39 | + // linked to directly rather than clicked toward. `[` and `]` step through. | |
| 40 | + const [stepId, setStepId] = useState<StepId>(stepFromHash); | |
| 41 | + const [overrides, setOverrides] = useState<{ | |
| 42 | + readonly integrations?: readonly DemoIntegration[]; | |
| 43 | + readonly connectOpen?: boolean; | |
| 44 | + readonly accountModal?: AccountModal; | |
| 45 | + readonly view?: ScreenView; | |
| 46 | + }>({}); | |
| 47 | + | |
| 48 | + const base = useMemo(() => stepState[stepId], [stepId]); | |
| 49 | + const state = { ...base, ...overrides }; | |
| 50 | + | |
| 51 | + const goToStep = useCallback((id: StepId) => { | |
| 52 | + setStepId(id); | |
| 53 | + setOverrides({}); | |
| 54 | + if (globalThis.location) globalThis.location.hash = id; | |
| 55 | + }, []); | |
| 56 | + | |
| 57 | + useEffect(() => { | |
| 58 | + const onHashChange = () => { | |
| 59 | + setStepId(stepFromHash()); | |
| 60 | + setOverrides({}); | |
| 61 | + }; | |
| 62 | + globalThis.addEventListener("hashchange", onHashChange); | |
| 63 | + return () => globalThis.removeEventListener("hashchange", onHashChange); | |
| 64 | + }, []); | |
| 65 | + | |
| 66 | + useEffect(() => { | |
| 67 | + const onKey = (event: KeyboardEvent) => { | |
| 68 | + if (event.key !== "[" && event.key !== "]") return; | |
| 69 | + const index = flowSteps.findIndex((candidate) => candidate.id === stepId); | |
| 70 | + const next = event.key === "]" ? index + 1 : index - 1; | |
| 71 | + const target = flowSteps[next]; | |
| 72 | + if (target) goToStep(target.id); | |
| 73 | + }; | |
| 74 | + globalThis.addEventListener("keydown", onKey); | |
| 75 | + return () => globalThis.removeEventListener("keydown", onKey); | |
| 76 | + }, [stepId, goToStep]); | |
| 77 | + | |
| 78 | + const integrations = state.integrations ?? seededIntegrations; | |
| 79 | + | |
| 80 | + return ( | |
| 81 | + <> | |
| 82 | + <Shell integrations={integrations}> | |
| 83 | + {state.view === "integrations" && ( | |
| 84 | + <IntegrationsPage | |
| 85 | + integrations={integrations} | |
| 86 | + onConnect={() => setOverrides((prev) => ({ ...prev, connectOpen: true }))} | |
| 87 | + onOpenIntegration={() => setOverrides((prev) => ({ ...prev, view: "detail" }))} | |
| 88 | + /> | |
| 89 | + )} | |
| 90 | + | |
| 91 | + {state.view === "add" && ( | |
| 92 | + <AddOpenApiPage | |
| 93 | + preset={posthogPreset} | |
| 94 | + onCancel={() => goToStep("integrations-empty")} | |
| 95 | + onComplete={() => goToStep("detail-accounts")} | |
| 96 | + /> | |
| 97 | + )} | |
| 98 | + | |
| 99 | + {state.view === "detail" && ( | |
| 100 | + <IntegrationDetailPage | |
| 101 | + integration={ | |
| 102 | + state.accountModal === "oauth-stuck" ? gmailIntegration : posthogIntegration | |
| 103 | + } | |
| 104 | + onAddConnection={() => | |
| 105 | + setOverrides((prev) => ({ ...prev, accountModal: "credential" })) | |
| 106 | + } | |
| 107 | + /> | |
| 108 | + )} | |
| 109 | + </Shell> | |
| 110 | + | |
| 111 | + <ConnectDialog | |
| 112 | + open={state.connectOpen === true} | |
| 113 | + onOpenChange={(open) => setOverrides((prev) => ({ ...prev, connectOpen: open }))} | |
| 114 | + onPickPreset={() => goToStep("add-openapi")} | |
| 115 | + onPickPlugin={() => goToStep("add-openapi")} | |
| 116 | + onPickCatalogEntry={() => goToStep("add-openapi")} | |
| 117 | + /> | |
| 118 | + | |
| 119 | + {state.accountModal !== "none" && ( | |
| 120 | + <AddAccountModal | |
| 121 | + // Remount per variant: a modal that stays mounted across a step | |
| 122 | + // change keeps the previous step's wizard position. | |
| 123 | + key={state.accountModal} | |
| 124 | + open | |
| 125 | + onOpenChange={(open) => | |
| 126 | + setOverrides((prev) => ({ ...prev, accountModal: open ? prev.accountModal : "none" })) | |
| 127 | + } | |
| 128 | + integrationName={state.accountModal === "oauth-stuck" ? "Gmail" : "PostHog"} | |
| 129 | + methods={state.accountModal === "oauth-stuck" ? gmailAuthMethods : posthogAuthMethods} | |
| 130 | + initialStep={state.accountModal === "place" ? "place" : "validate"} | |
| 131 | + stuckConnecting={state.accountModal === "oauth-stuck"} | |
| 132 | + onAdded={() => goToStep("integrations-populated")} | |
| 133 | + /> | |
| 134 | + )} | |
| 135 | + </> | |
| 136 | + ); | |
| 137 | +} | |
| 138 | + | |
| 139 | +// --------------------------------------------------------------------------- | |
| 140 | +// Mode switch | |
| 141 | +// | |
| 142 | +// Both flows run in the same console chrome, so the comparison is about the | |
| 143 | +// flow rather than the styling. The switch is a small floating control rather | |
| 144 | +// than a panel: it is scaffolding for looking at the prototype, and it should | |
| 145 | +// not take width away from the thing being looked at. | |
| 146 | +// --------------------------------------------------------------------------- | |
| 147 | + | |
| 148 | +type Mode = "current" | "next"; | |
| 149 | + | |
| 150 | +function ModeSwitch(props: { readonly mode: Mode; readonly onChange: (mode: Mode) => void }) { | |
| 151 | + const option = (mode: Mode, label: string) => ( | |
| 152 | + // oxlint-disable-next-line react/forbid-elements | |
| 153 | + <button | |
| 154 | + key={mode} | |
| 155 | + type="button" | |
| 156 | + onClick={() => props.onChange(mode)} | |
| 157 | + className={cn( | |
| 158 | + "rounded-full px-2.5 py-1 text-xs font-medium transition-colors", | |
| 159 | + props.mode === mode | |
| 160 | + ? "bg-foreground text-background" | |
| 161 | + : "text-muted-foreground hover:text-foreground", | |
| 162 | + )} | |
| 163 | + > | |
| 164 | + {label} | |
| 165 | + </button> | |
| 166 | + ); | |
| 167 | + return ( | |
| 168 | + <div className="fixed bottom-4 right-4 z-50 flex items-center gap-0.5 rounded-full border border-border bg-card/95 p-1 shadow-lg backdrop-blur-sm"> | |
| 169 | + {option("current", "Current")} | |
| 170 | + {option("next", "Reworked")} | |
| 171 | + </div> | |
| 172 | + ); | |
| 173 | +} | |
| 174 | + | |
| 175 | +const modeFromHash = (): Mode => { | |
| 176 | + const hash = globalThis.location?.hash ?? ""; | |
| 177 | + return hash.startsWith("#reworked") || hash.startsWith("#next") ? "next" : "current"; | |
| 178 | +}; | |
| 179 | + | |
| 180 | +export function App() { | |
| 181 | + const [mode, setMode] = useState<Mode>(modeFromHash); | |
| 182 | + | |
| 183 | + useEffect(() => { | |
| 184 | + const onHashChange = () => setMode(modeFromHash()); | |
| 185 | + globalThis.addEventListener("hashchange", onHashChange); | |
| 186 | + return () => globalThis.removeEventListener("hashchange", onHashChange); | |
| 187 | + }, []); | |
| 188 | + | |
| 189 | + const changeMode = (next: Mode) => { | |
| 190 | + setMode(next); | |
| 191 | + if (globalThis.location) { | |
| 192 | + globalThis.location.hash = next === "next" ? "reworked" : "integrations-empty"; | |
| 193 | + } | |
| 194 | + }; | |
| 195 | + | |
| 196 | + return ( | |
| 197 | + <div className="flex h-dvh w-full overflow-hidden bg-background text-foreground"> | |
| 198 | + {mode === "next" ? <NextFlow /> : <CurrentFlowApp />} | |
| 199 | + <ModeSwitch mode={mode} onChange={changeMode} /> | |
| 200 | + </div> | |
| 201 | + ); | |
| 202 | +} | |
| @@ -0,0 +1,163 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// The catalog behind the reworked picker. | |
| 3 | +// | |
| 4 | +// Everything here except the category is real registry data, loaded live from | |
| 5 | +// integrations.sh: ~3.4k domains, popularity-sorted, with icons, descriptions | |
| 6 | +// and the formats each one exposes. | |
| 7 | +// | |
| 8 | +// CATEGORY IS A STAND-IN. integrations.sh has a `categories` field, but only | |
| 9 | +// the 13 hand-curated entries populate it — the other ~3,365 domains have none. | |
| 10 | +// A category rail like the reference picker's therefore needs the catalog | |
| 11 | +// categorised first (the "leverage AI for the generation" step). The keyword | |
| 12 | +// pass below exists so the rail is real enough to design against; it is not a | |
| 13 | +// taxonomy and should not survive into the product. | |
| 14 | +// --------------------------------------------------------------------------- | |
| 15 | + | |
| 16 | +import { fetchCatalogDomains, type CatalogDomain } from "./fixtures"; | |
| 17 | + | |
| 18 | +export type Category = | |
| 19 | + | "Featured" | |
| 20 | + | "Inbox & calendar" | |
| 21 | + | "Docs & files" | |
| 22 | + | "Developer tools" | |
| 23 | + | "Analytics" | |
| 24 | + | "Payments" | |
| 25 | + | "Support" | |
| 26 | + | "Project management" | |
| 27 | + | "Infrastructure" | |
| 28 | + | "Everything else"; | |
| 29 | + | |
| 30 | +export const CATEGORY_ORDER: readonly Category[] = [ | |
| 31 | + "Featured", | |
| 32 | + "Inbox & calendar", | |
| 33 | + "Docs & files", | |
| 34 | + "Developer tools", | |
| 35 | + "Analytics", | |
| 36 | + "Payments", | |
| 37 | + "Support", | |
| 38 | + "Project management", | |
| 39 | + "Infrastructure", | |
| 40 | + "Everything else", | |
| 41 | +]; | |
| 42 | + | |
| 43 | +const KEYWORDS: readonly (readonly [Category, readonly string[]])[] = [ | |
| 44 | + ["Inbox & calendar", ["email", "mail", "calendar", "inbox", "meeting", "scheduling"]], | |
| 45 | + ["Docs & files", ["document", "file", "drive", "storage", "note", "wiki", "knowledge"]], | |
| 46 | + ["Analytics", ["analytic", "metric", "tracking", "telemetry", "dashboard", "insight"]], | |
| 47 | + ["Payments", ["payment", "invoice", "billing", "subscription", "checkout", "payout"]], | |
| 48 | + ["Support", ["support", "ticket", "helpdesk", "customer", "chat"]], | |
| 49 | + ["Project management", ["issue", "task", "project", "sprint", "roadmap", "backlog"]], | |
| 50 | + [ | |
| 51 | + "Infrastructure", | |
| 52 | + ["deploy", "server", "cluster", "database", "kubernetes", "cloud", "hosting", "dns"], | |
| 53 | + ], | |
| 54 | + ["Developer tools", ["repo", "commit", "pull request", "ci", "build", "code", "sdk", "api"]], | |
| 55 | +]; | |
| 56 | + | |
| 57 | +/** Best-effort bucket from the description. Deliberately crude — see the file | |
| 58 | + * header. Anything unmatched lands in "Everything else" rather than being | |
| 59 | + * guessed at. */ | |
| 60 | +const deriveCategory = (entry: CatalogDomain): Category => { | |
| 61 | + const haystack = `${entry.domain} ${entry.description}`.toLowerCase(); | |
| 62 | + for (const [category, words] of KEYWORDS) { | |
| 63 | + if (words.some((word) => haystack.includes(word))) return category; | |
| 64 | + } | |
| 65 | + return "Everything else"; | |
| 66 | +}; | |
| 67 | + | |
| 68 | +/** Domains the reference picker would call "Featured": the ones executor | |
| 69 | + * already ships a curated preset for, which are also the ones most people | |
| 70 | + * arrive wanting. */ | |
| 71 | +const FEATURED_DOMAINS: readonly string[] = [ | |
| 72 | + "google.com", | |
| 73 | + "github.com", | |
| 74 | + "linear.app", | |
| 75 | + "notion.com", | |
| 76 | + "slack.com", | |
| 77 | + "stripe.com", | |
| 78 | + "figma.com", | |
| 79 | + "posthog.com", | |
| 80 | + "sentry.io", | |
| 81 | + "vercel.com", | |
| 82 | + "resend.com", | |
| 83 | + "asana.com", | |
| 84 | +]; | |
| 85 | + | |
| 86 | +export interface CatalogItem { | |
| 87 | + readonly domain: string; | |
| 88 | + readonly name: string; | |
| 89 | + readonly description: string; | |
| 90 | + readonly icon: string; | |
| 91 | + readonly formats: readonly string[]; | |
| 92 | + readonly category: Category; | |
| 93 | + readonly featured: boolean; | |
| 94 | + readonly popularity: number; | |
| 95 | +} | |
| 96 | + | |
| 97 | +/** Title-case a domain into something that reads like a product name. | |
| 98 | + * `linear.app` → `Linear`, `google.com` → `Google`. */ | |
| 99 | +const displayName = (domain: string): string => { | |
| 100 | + const stem = domain.split(".")[0] ?? domain; | |
| 101 | + return stem.charAt(0).toUpperCase() + stem.slice(1); | |
| 102 | +}; | |
| 103 | + | |
| 104 | +export const loadCatalog = async (): Promise<readonly CatalogItem[]> => { | |
| 105 | + const domains = await fetchCatalogDomains(); | |
| 106 | + return domains.map((entry): CatalogItem => { | |
| 107 | + const featured = FEATURED_DOMAINS.includes(entry.domain); | |
| 108 | + return { | |
| 109 | + domain: entry.domain, | |
| 110 | + name: displayName(entry.domain), | |
| 111 | + description: entry.description, | |
| 112 | + icon: entry.icon, | |
| 113 | + formats: Object.keys(entry.formats ?? {}), | |
| 114 | + category: featured ? "Featured" : deriveCategory(entry), | |
| 115 | + featured, | |
| 116 | + popularity: entry.popularity, | |
| 117 | + }; | |
| 118 | + }); | |
| 119 | +}; | |
| 120 | + | |
| 121 | +export const searchItems = ( | |
| 122 | + items: readonly CatalogItem[], | |
| 123 | + query: string, | |
| 124 | +): readonly CatalogItem[] => { | |
| 125 | + const q = query.trim().toLowerCase(); | |
| 126 | + if (q.length === 0) return items; | |
| 127 | + return items.filter((item) => | |
| 128 | + `${item.domain} ${item.name} ${item.description}`.toLowerCase().includes(q), | |
| 129 | + ); | |
| 130 | +}; | |
| 131 | + | |
| 132 | +// --------------------------------------------------------------------------- | |
| 133 | +// Added integrations | |
| 134 | +// | |
| 135 | +// A registry pick and a hand-pasted URL become the SAME kind of thing the | |
| 136 | +// moment they're added. Only `source` differs, and it only affects what the | |
| 137 | +// detail screen can say about where the definition came from — never how you | |
| 138 | +// authenticate it or where it lives. | |
| 139 | +// --------------------------------------------------------------------------- | |
| 140 | + | |
| 141 | +export interface AddedIntegration { | |
| 142 | + readonly domain: string; | |
| 143 | + readonly name: string; | |
| 144 | + readonly description: string; | |
| 145 | + readonly icon: string; | |
| 146 | + readonly formats: readonly string[]; | |
| 147 | + readonly source: "registry" | "custom"; | |
| 148 | + /** Custom only: the endpoint or spec URL the user pasted. */ | |
| 149 | + readonly url?: string; | |
| 150 | +} | |
| 151 | + | |
| 152 | +export const fromCatalogItem = (item: CatalogItem): AddedIntegration => ({ | |
| 153 | + domain: item.domain, | |
| 154 | + name: item.name, | |
| 155 | + description: item.description, | |
| 156 | + icon: item.icon, | |
| 157 | + formats: item.formats, | |
| 158 | + source: "registry", | |
| 159 | +}); | |
| 160 | + | |
| 161 | +/** integrations.sh serves a favicon for any domain, registry entry or not, so | |
| 162 | + * a custom integration still gets a real logo. */ | |
| 163 | +export const logoFor = (domain: string): string => `https://integrations.sh/logo/${domain}`; | |
| @@ -0,0 +1,240 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Everything the reproduction renders from. | |
| 3 | +// | |
| 4 | +// Presets come from the REAL plugin modules, so the curated list here is the | |
| 5 | +// same list the console shows. Connections, tools and auth methods are static | |
| 6 | +// stand-ins for the API reads the console makes. The integrations.sh calls are | |
| 7 | +// the genuine public endpoints — that catalog is already client-callable, which | |
| 8 | +// is exactly why it is the obvious raw material for the rework. | |
| 9 | +// --------------------------------------------------------------------------- | |
| 10 | + | |
| 11 | +import { openApiPresets, type OpenApiPreset } from "@executor-js/plugin-openapi/presets"; | |
| 12 | +import { mcpPresets, type McpPreset } from "@executor-js/plugin-mcp/presets"; | |
| 13 | + | |
| 14 | +export type PluginKey = "openapi" | "mcp" | "graphql" | "google"; | |
| 15 | + | |
| 16 | +export interface DemoPreset { | |
| 17 | + readonly id: string; | |
| 18 | + readonly name: string; | |
| 19 | + readonly summary: string; | |
| 20 | + readonly icon?: string; | |
| 21 | + readonly url?: string; | |
| 22 | + readonly pluginKey: PluginKey; | |
| 23 | + readonly pluginLabel: string; | |
| 24 | +} | |
| 25 | + | |
| 26 | +const fromOpenApi = (preset: OpenApiPreset): DemoPreset => ({ | |
| 27 | + id: preset.id, | |
| 28 | + name: preset.name, | |
| 29 | + summary: preset.summary, | |
| 30 | + ...(preset.icon ? { icon: preset.icon } : {}), | |
| 31 | + ...(preset.url ? { url: preset.url } : {}), | |
| 32 | + pluginKey: "openapi", | |
| 33 | + pluginLabel: "OpenAPI", | |
| 34 | +}); | |
| 35 | + | |
| 36 | +const fromMcp = (preset: McpPreset): DemoPreset => ({ | |
| 37 | + id: preset.id, | |
| 38 | + name: preset.name, | |
| 39 | + summary: preset.summary, | |
| 40 | + ...(preset.icon ? { icon: preset.icon } : {}), | |
| 41 | + ...("url" in preset && preset.url ? { url: preset.url } : {}), | |
| 42 | + pluginKey: "mcp", | |
| 43 | + pluginLabel: "MCP", | |
| 44 | +}); | |
| 45 | + | |
| 46 | +/** The console's "Popular integrations" list, in the console's own order: | |
| 47 | + * every loaded plugin's presets, concatenated plugin by plugin. */ | |
| 48 | +export const curatedPresets: readonly DemoPreset[] = [ | |
| 49 | + ...openApiPresets.map(fromOpenApi), | |
| 50 | + ...mcpPresets.map(fromMcp), | |
| 51 | +]; | |
| 52 | + | |
| 53 | +/** The plugin chips under "Or add manually". */ | |
| 54 | +export const integrationPlugins: readonly { readonly key: PluginKey; readonly label: string }[] = [ | |
| 55 | + { key: "openapi", label: "OpenAPI" }, | |
| 56 | + { key: "mcp", label: "MCP" }, | |
| 57 | + { key: "graphql", label: "GraphQL" }, | |
| 58 | + { key: "google", label: "Google" }, | |
| 59 | +]; | |
| 60 | + | |
| 61 | +// --------------------------------------------------------------------------- | |
| 62 | +// Workspace state — what a brand-new tenant has, which is nothing | |
| 63 | +// --------------------------------------------------------------------------- | |
| 64 | + | |
| 65 | +export interface DemoIntegration { | |
| 66 | + readonly slug: string; | |
| 67 | + readonly name: string; | |
| 68 | + readonly kind: PluginKey; | |
| 69 | + readonly icon?: string; | |
| 70 | + readonly toolCount: number; | |
| 71 | +} | |
| 72 | + | |
| 73 | +export const seededIntegrations: readonly DemoIntegration[] = []; | |
| 74 | + | |
| 75 | +/** What the workspace looks like after the user adds PostHog — the state Theo | |
| 76 | + * reached before hunting for the key field. */ | |
| 77 | +export const posthogIntegration: DemoIntegration = { | |
| 78 | + slug: "posthog", | |
| 79 | + name: "PostHog", | |
| 80 | + kind: "openapi", | |
| 81 | + icon: "https://integrations.sh/logo/posthog.com", | |
| 82 | + toolCount: 0, | |
| 83 | +}; | |
| 84 | + | |
| 85 | +/** The OAuth-only integration, for the step where the popup dies. */ | |
| 86 | +export const gmailIntegration: DemoIntegration = { | |
| 87 | + slug: "gmail", | |
| 88 | + name: "Gmail", | |
| 89 | + kind: "google", | |
| 90 | + icon: "https://integrations.sh/logo/google.com", | |
| 91 | + toolCount: 0, | |
| 92 | +}; | |
| 93 | + | |
| 94 | +// --------------------------------------------------------------------------- | |
| 95 | +// Auth methods for the add-connection modal | |
| 96 | +// | |
| 97 | +// Shape mirrors `AuthMethod` in packages/react/src/lib/auth-placements.tsx. | |
| 98 | +// The labels are the ones a spec-derived integration actually produces: an | |
| 99 | +// OpenAPI spec whose security schemes carry no human name yields ordinal | |
| 100 | +// method labels, which is the "Method 1" tab. | |
| 101 | +// --------------------------------------------------------------------------- | |
| 102 | + | |
| 103 | +export interface DemoPlacement { | |
| 104 | + readonly carrier: "header" | "query" | "env"; | |
| 105 | + readonly name: string; | |
| 106 | + readonly prefix: string; | |
| 107 | +} | |
| 108 | + | |
| 109 | +export interface DemoAuthMethod { | |
| 110 | + readonly id: string; | |
| 111 | + readonly label: string; | |
| 112 | + readonly kind: "apikey" | "oauth" | "none"; | |
| 113 | + readonly placements: readonly DemoPlacement[]; | |
| 114 | +} | |
| 115 | + | |
| 116 | +export const posthogAuthMethods: readonly DemoAuthMethod[] = [ | |
| 117 | + { | |
| 118 | + id: "method-1", | |
| 119 | + label: "Method 1", | |
| 120 | + kind: "apikey", | |
| 121 | + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | |
| 122 | + }, | |
| 123 | + { | |
| 124 | + id: "method-2", | |
| 125 | + label: "Method 2", | |
| 126 | + kind: "apikey", | |
| 127 | + placements: [{ carrier: "query", name: "personal_api_key", prefix: "" }], | |
| 128 | + }, | |
| 129 | + { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] }, | |
| 130 | +]; | |
| 131 | + | |
| 132 | +export const gmailAuthMethods: readonly DemoAuthMethod[] = [ | |
| 133 | + { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] }, | |
| 134 | +]; | |
| 135 | + | |
| 136 | +// --------------------------------------------------------------------------- | |
| 137 | +// integrations.sh — the live public catalog | |
| 138 | +// --------------------------------------------------------------------------- | |
| 139 | + | |
| 140 | +export const INTEGRATIONS_SH_ORIGIN = "https://integrations.sh"; | |
| 141 | + | |
| 142 | +export const CONNECTABLE_KINDS = ["mcp", "openapi", "graphql"] as const; | |
| 143 | +export type CatalogKind = (typeof CONNECTABLE_KINDS)[number]; | |
| 144 | + | |
| 145 | +export interface CatalogEntry { | |
| 146 | + readonly domain: string; | |
| 147 | + readonly description: string; | |
| 148 | + readonly kinds: readonly CatalogKind[]; | |
| 149 | +} | |
| 150 | + | |
| 151 | +export const catalogLogoUrl = (domain: string, size: number): string => | |
| 152 | + `${INTEGRATIONS_SH_ORIGIN}/logo/${domain}?sz=${size * 2}`; | |
| 153 | + | |
| 154 | +const isConnectableKind = (kind: string): kind is CatalogKind => | |
| 155 | + (CONNECTABLE_KINDS as readonly string[]).includes(kind); | |
| 156 | + | |
| 157 | +/** `/api/search` — the same endpoint the console's connect dialog calls, and | |
| 158 | + * the same 250ms-debounced, ≥2-character contract. */ | |
| 159 | +export const searchCatalog = async ( | |
| 160 | + query: string, | |
| 161 | + limit = 10, | |
| 162 | +): Promise<readonly CatalogEntry[]> => { | |
| 163 | + const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN); | |
| 164 | + url.searchParams.set("q", query); | |
| 165 | + url.searchParams.set("limit", String(limit)); | |
| 166 | + const response = await fetch(url); | |
| 167 | + if (!response.ok) return []; | |
| 168 | + const payload = (await response.json()) as { | |
| 169 | + readonly results?: readonly { | |
| 170 | + readonly domain: string; | |
| 171 | + readonly description: string; | |
| 172 | + readonly kinds: readonly string[]; | |
| 173 | + }[]; | |
| 174 | + }; | |
| 175 | + return (payload.results ?? []) | |
| 176 | + .map((entry) => ({ | |
| 177 | + domain: entry.domain, | |
| 178 | + description: entry.description, | |
| 179 | + kinds: entry.kinds.filter(isConnectableKind), | |
| 180 | + })) | |
| 181 | + .filter((entry) => entry.kinds.length > 0); | |
| 182 | +}; | |
| 183 | + | |
| 184 | +export interface CatalogDomain { | |
| 185 | + readonly domain: string; | |
| 186 | + readonly icon: string; | |
| 187 | + readonly description: string; | |
| 188 | + readonly formats: Readonly<Record<string, number>>; | |
| 189 | + readonly popularity: number; | |
| 190 | +} | |
| 191 | + | |
| 192 | +/** `/api/domains.json` — the whole popularity-sorted registry (~3.4k domains). | |
| 193 | + * The console never calls this; it is here because it is the list the product | |
| 194 | + * is missing, and the rework will want it. */ | |
| 195 | +export const fetchCatalogDomains = async (): Promise<readonly CatalogDomain[]> => { | |
| 196 | + const response = await fetch(`${INTEGRATIONS_SH_ORIGIN}/api/domains.json`); | |
| 197 | + if (!response.ok) return []; | |
| 198 | + const payload = (await response.json()) as { readonly data?: readonly CatalogDomain[] }; | |
| 199 | + return payload.data ?? []; | |
| 200 | +}; | |
| 201 | + | |
| 202 | +export interface SurfaceCredential { | |
| 203 | + readonly id: string; | |
| 204 | + readonly type: string; | |
| 205 | + readonly label: string; | |
| 206 | + readonly generateUrl?: string; | |
| 207 | + readonly setup?: string; | |
| 208 | +} | |
| 209 | + | |
| 210 | +/** `/api/<domain>/surface` — the per-domain document. The console reads only | |
| 211 | + * `surfaces[].url` out of this; the `credentials` block (which key, where to | |
| 212 | + * mint it, how) is fetched and thrown away. */ | |
| 213 | +export const fetchSurfaceCredentials = async ( | |
| 214 | + domain: string, | |
| 215 | +): Promise<readonly SurfaceCredential[]> => { | |
| 216 | + const response = await fetch( | |
| 217 | + `${INTEGRATIONS_SH_ORIGIN}/api/${encodeURIComponent(domain)}/surface`, | |
| 218 | + ); | |
| 219 | + if (!response.ok) return []; | |
| 220 | + const payload = (await response.json()) as { | |
| 221 | + readonly credentials?: Readonly< | |
| 222 | + Record< | |
| 223 | + string, | |
| 224 | + { | |
| 225 | + readonly type?: string; | |
| 226 | + readonly label?: string; | |
| 227 | + readonly generateUrl?: string; | |
| 228 | + readonly setup?: string; | |
| 229 | + } | |
| 230 | + > | |
| 231 | + >; | |
| 232 | + }; | |
| 233 | + return Object.entries(payload.credentials ?? {}).map(([id, value]) => ({ | |
| 234 | + id, | |
| 235 | + type: value.type ?? "unknown", | |
| 236 | + label: value.label ?? id, | |
| 237 | + ...(value.generateUrl ? { generateUrl: value.generateUrl } : {}), | |
| 238 | + ...(value.setup ? { setup: value.setup } : {}), | |
| 239 | + })); | |
| 240 | +}; | |
| @@ -0,0 +1,111 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// The flow, as one ordered list of screens. | |
| 3 | +// | |
| 4 | +// Each step carries the first-run reaction recorded against that exact screen, | |
| 5 | +// so a redesign can be checked against the complaint it is meant to answer | |
| 6 | +// rather than against a general sense that the old screen was bad. | |
| 7 | +// --------------------------------------------------------------------------- | |
| 8 | + | |
| 9 | +export type StepId = | |
| 10 | + | "integrations-empty" | |
| 11 | + | "connect-dialog" | |
| 12 | + | "add-openapi" | |
| 13 | + | "detail-accounts" | |
| 14 | + | "add-account-credential" | |
| 15 | + | "add-account-place" | |
| 16 | + | "oauth-stuck" | |
| 17 | + | "integrations-populated"; | |
| 18 | + | |
| 19 | +export interface FlowStep { | |
| 20 | + readonly id: StepId; | |
| 21 | + readonly label: string; | |
| 22 | + /** Where this screen lives in the real console. */ | |
| 23 | + readonly route: string; | |
| 24 | + /** The component this reproduction is taken from. */ | |
| 25 | + readonly source: string; | |
| 26 | + /** Verbatim first-run reactions to this screen. */ | |
| 27 | + readonly reactions: readonly string[]; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export const flowSteps: readonly FlowStep[] = [ | |
| 31 | + { | |
| 32 | + id: "integrations-empty", | |
| 33 | + label: "Integrations (empty)", | |
| 34 | + route: "/", | |
| 35 | + source: "packages/react/src/pages/integrations.tsx", | |
| 36 | + reactions: [ | |
| 37 | + "Adding to clis was easy. Not clear what to do next.", | |
| 38 | + "Next is this, not a great screen to find integrations from. No text search? I just wanted to add email", | |
| 39 | + ], | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + id: "connect-dialog", | |
| 43 | + label: "Connect dialog", | |
| 44 | + route: "/ (dialog)", | |
| 45 | + source: "packages/react/src/pages/integrations.tsx › ConnectDialog", | |
| 46 | + reactions: [ | |
| 47 | + "I'd love a proper like picker grid and search", | |
| 48 | + "I want to add multiple to set up for experimentation. I clicked the first one I want (posthog) and it brought me here", | |
| 49 | + "Annoying because now I have to sit and wait for it to do whatever and then add, and I could be spending this time finding other integrations I want", | |
| 50 | + ], | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + id: "add-openapi", | |
| 54 | + label: "Add integration form", | |
| 55 | + route: "/integrations/add/openapi", | |
| 56 | + source: "packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx", | |
| 57 | + reactions: [ | |
| 58 | + "This view is a bit of a mess. There's no clear hierarchy on what exactly I'm supposed to be doing, what matters and doesn't, and what button I should hit. There's the big add integration at the bottom and the weird treatment around it. Just very unclear what the step is", | |
| 59 | + "Hit “add” and now another loading screen, well spinning wheel button", | |
| 60 | + ], | |
| 61 | + }, | |
| 62 | + { | |
| 63 | + id: "detail-accounts", | |
| 64 | + label: "Integration detail · Accounts", | |
| 65 | + route: "/integrations/posthog?tab=accounts", | |
| 66 | + source: "packages/react/src/pages/integration-detail.tsx", | |
| 67 | + reactions: [ | |
| 68 | + "wtf is this?? I just added it why do I have to add it again??", | |
| 69 | + "The hierarchy of the integrations and connections is not something I should have to understand or care about as I initially setup", | |
| 70 | + "I increasingly hate this view. The more that I look at it, there are just so many things demanding attention and none of them are the thing I actually want to do", | |
| 71 | + ], | |
| 72 | + }, | |
| 73 | + { | |
| 74 | + id: "add-account-credential", | |
| 75 | + label: "Add connection · credential", | |
| 76 | + route: "/integrations/posthog?tab=accounts&addAccount=1", | |
| 77 | + source: "packages/react/src/components/add-account-modal.tsx", | |
| 78 | + reactions: [ | |
| 79 | + "Where the fk do I paste the key?? The Method 1 thing is locked", | |
| 80 | + "I'm not scared of doing anything because I already copied the API key and it's on my clipboard but I can't paste it yet. I just have to not use my computer because I don't have clipboard history management and never will", | |
| 81 | + "I'm even scared to send a screenshot because I'm going to lose the key. I'm just going to have to recreate it. I think I'll throw it in a note.", | |
| 82 | + "Okay so what type of key do I need? Obviously doing this for every integration will be annoying but I'm sure you can get some agent to go do it.", | |
| 83 | + ], | |
| 84 | + }, | |
| 85 | + { | |
| 86 | + id: "add-account-place", | |
| 87 | + label: "Add connection · placement + owner", | |
| 88 | + route: "/integrations/posthog?tab=accounts&addAccount=1", | |
| 89 | + source: "packages/react/src/components/add-account-modal.tsx", | |
| 90 | + reactions: ["How many times does the word “personal” appear on this page? lol"], | |
| 91 | + }, | |
| 92 | + { | |
| 93 | + id: "oauth-stuck", | |
| 94 | + label: "OAuth · stuck connecting", | |
| 95 | + route: "/integrations/gmail?tab=accounts&addAccount=1", | |
| 96 | + source: "packages/react/src/plugins/oauth-sign-in.tsx", | |
| 97 | + reactions: [ | |
| 98 | + "Closed the broken auth page and now app is stuck in “connecting...”", | |
| 99 | + "On business Gmail, allowed me to click an “Advanced options” (“I trust the developer”) thing, but normal personal accounts, no", | |
| 100 | + ], | |
| 101 | + }, | |
| 102 | + { | |
| 103 | + id: "integrations-populated", | |
| 104 | + label: "Integrations (populated)", | |
| 105 | + route: "/", | |
| 106 | + source: "packages/react/src/pages/integrations.tsx", | |
| 107 | + reactions: ["This is filling me with rage for some reason"], | |
| 108 | + }, | |
| 109 | +]; | |
| 110 | + | |
| 111 | +export const stepIndex = (id: StepId): number => flowSteps.findIndex((step) => step.id === id); | |
| @@ -0,0 +1,12 @@ | ||
| 1 | +import { StrictMode } from "react"; | |
| 2 | +import { createRoot } from "react-dom/client"; | |
| 3 | +import { App } from "./app"; | |
| 4 | +import "./styles.css"; | |
| 5 | + | |
| 6 | +const root = document.getElementById("root"); | |
| 7 | +if (root) | |
| 8 | + createRoot(root).render( | |
| 9 | + <StrictMode> | |
| 10 | + <App /> | |
| 11 | + </StrictMode>, | |
| 12 | + ); | |
| @@ -0,0 +1,304 @@ | ||
| 1 | +// The reworked flow, wired end to end: browse → add (in place) → the added | |
| 2 | +// integration → authenticate, plus the custom-URL door that bypasses the | |
| 3 | +// registry entirely. Adding never blocks and never navigates; the only thing | |
| 4 | +// that moves you off the picker is choosing to look at something. | |
| 5 | +// | |
| 6 | +// Every screen is a hash route, so any of them can be linked to directly: | |
| 7 | +// #reworked the picker | |
| 8 | +// #reworked/installed what you've added | |
| 9 | +// #reworked/custom the custom-URL dialog | |
| 10 | +// #reworked/<domain> one integration | |
| 11 | +// #reworked/<domain>/auth …with its authenticate dialog open | |
| 12 | + | |
| 13 | +import { useCallback, useEffect, useState } from "react"; | |
| 14 | +import { Button } from "@executor-js/react/components/button"; | |
| 15 | +import { cn } from "@executor-js/react/lib/utils"; | |
| 16 | +import { Shell } from "./shell"; | |
| 17 | +import { BrowsePage } from "./screens/next/browse"; | |
| 18 | +import { | |
| 19 | + InstalledList, | |
| 20 | + NextIntegrationDetail, | |
| 21 | + type DemoAccount, | |
| 22 | +} from "./screens/next/integration-detail"; | |
| 23 | +import { AuthenticateDialog } from "./screens/next/authenticate"; | |
| 24 | +import { AddCustomDialog } from "./screens/next/custom"; | |
| 25 | +import { fromCatalogItem, type AddedIntegration, type CatalogItem } from "./catalog"; | |
| 26 | +import type { DemoIntegration } from "./fixtures"; | |
| 27 | + | |
| 28 | +interface Added { | |
| 29 | + readonly item: AddedIntegration; | |
| 30 | + readonly accounts: readonly DemoAccount[]; | |
| 31 | +} | |
| 32 | + | |
| 33 | +// --------------------------------------------------------------------------- | |
| 34 | +// Routing | |
| 35 | +// --------------------------------------------------------------------------- | |
| 36 | + | |
| 37 | +export type NextRoute = | |
| 38 | + | { readonly kind: "browse" } | |
| 39 | + | { readonly kind: "installed" } | |
| 40 | + | { readonly kind: "custom" } | |
| 41 | + | { readonly kind: "detail"; readonly domain: string } | |
| 42 | + | { readonly kind: "auth"; readonly domain: string }; | |
| 43 | + | |
| 44 | +const parseRoute = (hash: string): NextRoute => { | |
| 45 | + // `#reworked/...` is canonical; `#next/...` is kept working because it was | |
| 46 | + // the first spelling and may already be pasted somewhere. | |
| 47 | + const path = hash.replace(/^#/, "").replace(/^(reworked|next)\/?/, ""); | |
| 48 | + const segments = path.split("/").filter((segment) => segment.length > 0); | |
| 49 | + if (segments.length === 0) return { kind: "browse" }; | |
| 50 | + const [first, second] = segments; | |
| 51 | + if (first === "installed") return { kind: "installed" }; | |
| 52 | + if (first === "custom") return { kind: "custom" }; | |
| 53 | + if (first && second === "auth") return { kind: "auth", domain: first }; | |
| 54 | + if (first) return { kind: "detail", domain: first }; | |
| 55 | + return { kind: "browse" }; | |
| 56 | +}; | |
| 57 | + | |
| 58 | +const routeToHash = (route: NextRoute): string => { | |
| 59 | + if (route.kind === "browse") return "#reworked"; | |
| 60 | + if (route.kind === "installed") return "#reworked/installed"; | |
| 61 | + if (route.kind === "custom") return "#reworked/custom"; | |
| 62 | + if (route.kind === "auth") return `#reworked/${route.domain}/auth`; | |
| 63 | + return `#reworked/${route.domain}`; | |
| 64 | +}; | |
| 65 | + | |
| 66 | +// --------------------------------------------------------------------------- | |
| 67 | + | |
| 68 | +const toolsFor = (accounts: readonly DemoAccount[]): number => | |
| 69 | + accounts.some((account) => account.status === "connected") ? 24 : 0; | |
| 70 | + | |
| 71 | +/** What the console sidebar lists: the same added integrations, in the shape | |
| 72 | + * the shell already renders. */ | |
| 73 | +const asSidebarIntegration = (added: Added): DemoIntegration => ({ | |
| 74 | + slug: added.item.domain, | |
| 75 | + name: added.item.name, | |
| 76 | + kind: "openapi", | |
| 77 | + icon: added.item.icon, | |
| 78 | + toolCount: toolsFor(added.accounts), | |
| 79 | +}); | |
| 80 | + | |
| 81 | +export function NextFlow() { | |
| 82 | + const [added, setAdded] = useState<readonly Added[]>([]); | |
| 83 | + const [route, setRoute] = useState<NextRoute>(() => parseRoute(globalThis.location?.hash ?? "")); | |
| 84 | + | |
| 85 | + useEffect(() => { | |
| 86 | + const onHashChange = () => setRoute(parseRoute(globalThis.location?.hash ?? "")); | |
| 87 | + globalThis.addEventListener("hashchange", onHashChange); | |
| 88 | + return () => globalThis.removeEventListener("hashchange", onHashChange); | |
| 89 | + }, []); | |
| 90 | + | |
| 91 | + const go = useCallback((next: NextRoute) => { | |
| 92 | + setRoute(next); | |
| 93 | + if (globalThis.location) globalThis.location.hash = routeToHash(next); | |
| 94 | + }, []); | |
| 95 | + | |
| 96 | + const addIntegration = useCallback((item: AddedIntegration) => { | |
| 97 | + setAdded((previous) => | |
| 98 | + previous.some((entry) => entry.item.domain === item.domain) | |
| 99 | + ? previous | |
| 100 | + : [ | |
| 101 | + ...previous, | |
| 102 | + // An add always leaves exactly one account behind, already named | |
| 103 | + // and in the one state that matters. | |
| 104 | + { item, accounts: [{ label: "default", status: "needs-auth" as const }] }, | |
| 105 | + ], | |
| 106 | + ); | |
| 107 | + }, []); | |
| 108 | + | |
| 109 | + const addFromCatalog = useCallback( | |
| 110 | + (item: CatalogItem) => addIntegration(fromCatalogItem(item)), | |
| 111 | + [addIntegration], | |
| 112 | + ); | |
| 113 | + | |
| 114 | + const markConnected = useCallback((domain: string, accountLabel: string, identity: string) => { | |
| 115 | + setAdded((previous) => | |
| 116 | + previous.map((entry) => | |
| 117 | + entry.item.domain === domain | |
| 118 | + ? { | |
| 119 | + ...entry, | |
| 120 | + accounts: entry.accounts.map((account) => | |
| 121 | + account.label === accountLabel | |
| 122 | + ? { ...account, status: "connected" as const, identity } | |
| 123 | + : account, | |
| 124 | + ), | |
| 125 | + } | |
| 126 | + : entry, | |
| 127 | + ), | |
| 128 | + ); | |
| 129 | + }, []); | |
| 130 | + | |
| 131 | + const activeDomain = route.kind === "detail" || route.kind === "auth" ? route.domain : null; | |
| 132 | + const open = added.find((entry) => entry.item.domain === activeDomain) ?? null; | |
| 133 | + const pendingCount = added.filter((entry) => | |
| 134 | + entry.accounts.some((account) => account.status === "needs-auth"), | |
| 135 | + ).length; | |
| 136 | + | |
| 137 | + // A deep link can name a domain that has not been added in this session. | |
| 138 | + // Say so rather than rendering an empty detail screen. | |
| 139 | + const missing = activeDomain !== null && open === null; | |
| 140 | + | |
| 141 | + const showingInstalled = route.kind === "installed"; | |
| 142 | + const showingBrowse = route.kind === "browse" || route.kind === "custom"; | |
| 143 | + | |
| 144 | + return ( | |
| 145 | + <Shell integrations={added.map(asSidebarIntegration)}> | |
| 146 | + <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 147 | + <div className="mx-auto max-w-4xl px-8 py-10"> | |
| 148 | + {open && !missing ? ( | |
| 149 | + <NextIntegrationDetail | |
| 150 | + item={open.item} | |
| 151 | + accounts={open.accounts} | |
| 152 | + toolCount={toolsFor(open.accounts)} | |
| 153 | + onBack={() => go({ kind: "installed" })} | |
| 154 | + onAuthenticate={() => go({ kind: "auth", domain: open.item.domain })} | |
| 155 | + onAddAccount={() => | |
| 156 | + setAdded((previous) => | |
| 157 | + previous.map((entry) => | |
| 158 | + entry.item.domain === open.item.domain | |
| 159 | + ? { | |
| 160 | + ...entry, | |
| 161 | + accounts: [ | |
| 162 | + ...entry.accounts, | |
| 163 | + { | |
| 164 | + label: `account ${entry.accounts.length + 1}`, | |
| 165 | + status: "needs-auth" as const, | |
| 166 | + }, | |
| 167 | + ], | |
| 168 | + } | |
| 169 | + : entry, | |
| 170 | + ), | |
| 171 | + ) | |
| 172 | + } | |
| 173 | + onRemove={() => { | |
| 174 | + setAdded((previous) => | |
| 175 | + previous.filter((entry) => entry.item.domain !== open.item.domain), | |
| 176 | + ); | |
| 177 | + go({ kind: "installed" }); | |
| 178 | + }} | |
| 179 | + /> | |
| 180 | + ) : missing ? ( | |
| 181 | + <div className="rounded-lg border border-dashed border-border py-16 text-center"> | |
| 182 | + <p className="text-sm font-medium text-foreground"> | |
| 183 | + {activeDomain} isn't added in this session | |
| 184 | + </p> | |
| 185 | + <p className="mt-1 text-sm text-muted-foreground"> | |
| 186 | + This prototype keeps nothing between reloads. | |
| 187 | + </p> | |
| 188 | + <Button | |
| 189 | + type="button" | |
| 190 | + size="sm" | |
| 191 | + className="mt-4" | |
| 192 | + onClick={() => go({ kind: "browse" })} | |
| 193 | + > | |
| 194 | + Browse integrations | |
| 195 | + </Button> | |
| 196 | + </div> | |
| 197 | + ) : ( | |
| 198 | + <> | |
| 199 | + <div className="mb-6 flex items-center justify-between gap-4"> | |
| 200 | + <div className="flex items-center gap-0.5 rounded-lg bg-muted/60 p-0.5"> | |
| 201 | + {/* oxlint-disable-next-line react/forbid-elements */} | |
| 202 | + <button | |
| 203 | + type="button" | |
| 204 | + onClick={() => go({ kind: "browse" })} | |
| 205 | + className={cn( | |
| 206 | + "rounded-md px-3 py-1.5 text-sm font-medium transition-colors", | |
| 207 | + showingBrowse | |
| 208 | + ? "bg-background text-foreground shadow-xs" | |
| 209 | + : "text-muted-foreground hover:text-foreground", | |
| 210 | + )} | |
| 211 | + > | |
| 212 | + Browse | |
| 213 | + </button> | |
| 214 | + {/* oxlint-disable-next-line react/forbid-elements */} | |
| 215 | + <button | |
| 216 | + type="button" | |
| 217 | + onClick={() => go({ kind: "installed" })} | |
| 218 | + className={cn( | |
| 219 | + "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors", | |
| 220 | + showingInstalled | |
| 221 | + ? "bg-background text-foreground shadow-xs" | |
| 222 | + : "text-muted-foreground hover:text-foreground", | |
| 223 | + )} | |
| 224 | + > | |
| 225 | + Installed | |
| 226 | + {added.length > 0 ? ( | |
| 227 | + <span className="font-mono text-xs text-muted-foreground"> | |
| 228 | + {added.length} | |
| 229 | + </span> | |
| 230 | + ) : null} | |
| 231 | + </button> | |
| 232 | + </div> | |
| 233 | + {pendingCount > 0 ? ( | |
| 234 | + <span className="shrink-0 text-xs font-medium text-amber-500"> | |
| 235 | + {pendingCount} need{pendingCount === 1 ? "s" : ""} auth | |
| 236 | + </span> | |
| 237 | + ) : null} | |
| 238 | + </div> | |
| 239 | + | |
| 240 | + {showingInstalled ? ( | |
| 241 | + <InstalledList | |
| 242 | + items={added} | |
| 243 | + onOpen={(domain) => go({ kind: "detail", domain })} | |
| 244 | + onBrowse={() => go({ kind: "browse" })} | |
| 245 | + /> | |
| 246 | + ) : ( | |
| 247 | + <BrowsePage | |
| 248 | + addedDomains={added.map((entry) => entry.item.domain)} | |
| 249 | + onAdd={addFromCatalog} | |
| 250 | + onOpen={(item) => { | |
| 251 | + addFromCatalog(item); | |
| 252 | + go({ kind: "detail", domain: item.domain }); | |
| 253 | + }} | |
| 254 | + onAddCustom={() => go({ kind: "custom" })} | |
| 255 | + /> | |
| 256 | + )} | |
| 257 | + </> | |
| 258 | + )} | |
| 259 | + </div> | |
| 260 | + </div> | |
| 261 | + | |
| 262 | + {route.kind === "custom" ? ( | |
| 263 | + <AddCustomDialog | |
| 264 | + open | |
| 265 | + onOpenChange={(next) => { | |
| 266 | + if (!next) go({ kind: "browse" }); | |
| 267 | + }} | |
| 268 | + onAdd={(integration) => { | |
| 269 | + addIntegration(integration); | |
| 270 | + go({ kind: "detail", domain: integration.domain }); | |
| 271 | + }} | |
| 272 | + /> | |
| 273 | + ) : null} | |
| 274 | + | |
| 275 | + {route.kind === "auth" && open ? ( | |
| 276 | + <AuthenticateDialog | |
| 277 | + // Keyed per target so the dialog owns its state and a close throws it | |
| 278 | + // away, rather than carrying a half-finished attempt to the next one. | |
| 279 | + key={open.item.domain} | |
| 280 | + open | |
| 281 | + onOpenChange={(next) => { | |
| 282 | + if (!next) go({ kind: "detail", domain: open.item.domain }); | |
| 283 | + }} | |
| 284 | + integrationName={open.item.name} | |
| 285 | + domain={open.item.domain} | |
| 286 | + accountLabel={ | |
| 287 | + open.accounts.find((account) => account.status === "needs-auth")?.label ?? "default" | |
| 288 | + } | |
| 289 | + onAuthenticated={() => { | |
| 290 | + const account = | |
| 291 | + open.accounts.find((candidate) => candidate.status === "needs-auth")?.label ?? | |
| 292 | + "default"; | |
| 293 | + markConnected( | |
| 294 | + open.item.domain, | |
| 295 | + account, | |
| 296 | + `rhys@${open.item.domain.split(".")[0] ?? open.item.domain}`, | |
| 297 | + ); | |
| 298 | + go({ kind: "detail", domain: open.item.domain }); | |
| 299 | + }} | |
| 300 | + /> | |
| 301 | + ) : null} | |
| 302 | + </Shell> | |
| 303 | + ); | |
| 304 | +} | |
| @@ -0,0 +1,318 @@ | ||
| 1 | +// Reproduction of packages/react/src/components/add-account-modal.tsx. | |
| 2 | +// | |
| 3 | +// The credential path is a 2-step wizard behind a horizontally scrolling strip | |
| 4 | +// of auth-method tabs. Step 1 is the key plus an optional read-only probe; | |
| 5 | +// step 2 is the display name and the "Connection saved to" owner picker. The | |
| 6 | +// OAuth path is not a wizard and shows an app picker instead. | |
| 7 | +// | |
| 8 | +// The word "Personal" is reproduced at every site it occurs on the real screen: | |
| 9 | +// the derived connection name, the callable-name hint, the owner picker's label | |
| 10 | +// and its selected option. | |
| 11 | + | |
| 12 | +import { useState } from "react"; | |
| 13 | +import { EyeIcon, EyeOffIcon, PlusIcon } from "lucide-react"; | |
| 14 | +import { Button } from "@executor-js/react/components/button"; | |
| 15 | +import { Input } from "@executor-js/react/components/input"; | |
| 16 | +import { Label } from "@executor-js/react/components/label"; | |
| 17 | +import { | |
| 18 | + Dialog, | |
| 19 | + DialogContent, | |
| 20 | + DialogDescription, | |
| 21 | + DialogFooter, | |
| 22 | + DialogHeader, | |
| 23 | + DialogTitle, | |
| 24 | +} from "@executor-js/react/components/dialog"; | |
| 25 | +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; | |
| 26 | +import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; | |
| 27 | +import { cn } from "@executor-js/react/lib/utils"; | |
| 28 | +import type { DemoAuthMethod } from "../fixtures"; | |
| 29 | + | |
| 30 | +function StepHeader(props: { readonly label: string; readonly hint?: string }) { | |
| 31 | + return ( | |
| 32 | + <div className="flex flex-col gap-1"> | |
| 33 | + <Label className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> | |
| 34 | + {props.label} | |
| 35 | + </Label> | |
| 36 | + {props.hint ? <span className="text-xs text-muted-foreground/70">{props.hint}</span> : null} | |
| 37 | + </div> | |
| 38 | + ); | |
| 39 | +} | |
| 40 | + | |
| 41 | +function PlacementLine(props: { readonly placement: DemoAuthMethod["placements"][number] }) { | |
| 42 | + const { placement } = props; | |
| 43 | + const lead = | |
| 44 | + placement.carrier === "header" | |
| 45 | + ? `${placement.name || "Authorization"}: ` | |
| 46 | + : placement.carrier === "env" | |
| 47 | + ? `${placement.name || "TOKEN"}=` | |
| 48 | + : `?${placement.name || "api_key"}=`; | |
| 49 | + return ( | |
| 50 | + <span className="wrap-anywhere whitespace-pre-wrap font-mono text-xs text-muted-foreground"> | |
| 51 | + {lead} | |
| 52 | + {placement.prefix ? ( | |
| 53 | + <span className="text-muted-foreground/60">{placement.prefix}</span> | |
| 54 | + ) : null} | |
| 55 | + <span className="tracking-widest text-primary">••••••</span> | |
| 56 | + </span> | |
| 57 | + ); | |
| 58 | +} | |
| 59 | + | |
| 60 | +export function AddAccountModal(props: { | |
| 61 | + readonly open: boolean; | |
| 62 | + readonly onOpenChange: (open: boolean) => void; | |
| 63 | + readonly integrationName: string; | |
| 64 | + readonly methods: readonly DemoAuthMethod[]; | |
| 65 | + /** Start on the wizard's second step, which is the screen the owner picker | |
| 66 | + * and its repeated "Personal" live on. */ | |
| 67 | + readonly initialStep?: "validate" | "place"; | |
| 68 | + /** Reproduce the post-OAuth stuck state: the popup was closed, and the | |
| 69 | + * button never leaves "Connecting…". */ | |
| 70 | + readonly stuckConnecting?: boolean; | |
| 71 | + readonly onAdded: () => void; | |
| 72 | +}) { | |
| 73 | + const [methodId, setMethodId] = useState(props.methods[0]?.id ?? ""); | |
| 74 | + const [wizardStep, setWizardStep] = useState<"validate" | "place">( | |
| 75 | + props.initialStep ?? "validate", | |
| 76 | + ); | |
| 77 | + const [secret, setSecret] = useState(""); | |
| 78 | + const [revealed, setRevealed] = useState(false); | |
| 79 | + const [label, setLabel] = useState(""); | |
| 80 | + const [owner, setOwner] = useState<"user" | "org">("user"); | |
| 81 | + | |
| 82 | + const method = props.methods.find((candidate) => candidate.id === methodId) ?? props.methods[0]; | |
| 83 | + const isOAuth = method?.kind === "oauth"; | |
| 84 | + const wizardActive = !isOAuth; | |
| 85 | + const showValidateStep = !wizardActive || wizardStep === "validate"; | |
| 86 | + const showPlaceStep = !wizardActive || wizardStep === "place"; | |
| 87 | + | |
| 88 | + const ownerWord = owner === "user" ? "Personal" : "Workspace"; | |
| 89 | + const derivedName = `${ownerWord} ${props.integrationName}`; | |
| 90 | + const callableName = (label.trim() || derivedName).toLowerCase().replace(/[^a-z0-9]+/g, "_"); | |
| 91 | + | |
| 92 | + const singlePlacement = method?.placements[0]; | |
| 93 | + const affix = | |
| 94 | + singlePlacement && singlePlacement.carrier === "header" | |
| 95 | + ? `${singlePlacement.name}: ${singlePlacement.prefix}` | |
| 96 | + : null; | |
| 97 | + | |
| 98 | + return ( | |
| 99 | + <Dialog open={props.open} onOpenChange={props.onOpenChange} modal={false}> | |
| 100 | + <DialogContent forceOverlay className="max-h-[85vh] overflow-y-auto sm:max-w-xl"> | |
| 101 | + <DialogHeader> | |
| 102 | + <DialogTitle> | |
| 103 | + Add connection · {props.integrationName} | |
| 104 | + {wizardActive ? ( | |
| 105 | + <span className="ml-2 font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> | |
| 106 | + Step {wizardStep === "validate" ? 1 : 2} of 2 | |
| 107 | + </span> | |
| 108 | + ) : null} | |
| 109 | + </DialogTitle> | |
| 110 | + <DialogDescription> | |
| 111 | + A connection is a saved way to use this integration, owned by you or the workspace. | |
| 112 | + </DialogDescription> | |
| 113 | + </DialogHeader> | |
| 114 | + | |
| 115 | + <div className="flex w-full min-w-0 flex-col gap-5"> | |
| 116 | + {showValidateStep && ( | |
| 117 | + <Tabs | |
| 118 | + value={methodId} | |
| 119 | + onValueChange={(next: string) => { | |
| 120 | + setMethodId(next); | |
| 121 | + setWizardStep("validate"); | |
| 122 | + }} | |
| 123 | + className="w-full min-w-0 max-w-full gap-0" | |
| 124 | + > | |
| 125 | + <TabsList className="flex h-10 w-full min-w-0 max-w-full justify-start overflow-x-auto overflow-y-hidden rounded-b-none rounded-t-md border border-b-0 border-border/60 bg-muted/30 p-1 [scrollbar-width:thin]"> | |
| 126 | + <div className="flex w-max shrink-0 items-stretch gap-1"> | |
| 127 | + {props.methods.map((candidate) => ( | |
| 128 | + <div | |
| 129 | + key={candidate.id} | |
| 130 | + className="group/method-tab relative flex h-8 shrink-0 items-stretch" | |
| 131 | + > | |
| 132 | + <TabsTrigger | |
| 133 | + value={candidate.id} | |
| 134 | + className="h-full max-w-64 shrink-0 justify-start px-3 text-sm font-medium" | |
| 135 | + > | |
| 136 | + <span className="truncate">{candidate.label}</span> | |
| 137 | + </TabsTrigger> | |
| 138 | + </div> | |
| 139 | + ))} | |
| 140 | + <Button | |
| 141 | + type="button" | |
| 142 | + variant="ghost" | |
| 143 | + size="icon-sm" | |
| 144 | + aria-label="Add authentication method" | |
| 145 | + className="h-8 shrink-0 rounded-md border border-transparent bg-transparent px-3 text-foreground/60 hover:bg-background/60" | |
| 146 | + > | |
| 147 | + <PlusIcon className="size-4" /> | |
| 148 | + </Button> | |
| 149 | + </div> | |
| 150 | + </TabsList> | |
| 151 | + | |
| 152 | + <TabsContent | |
| 153 | + value={methodId} | |
| 154 | + className={cn( | |
| 155 | + "mt-0 min-w-0 space-y-5", | |
| 156 | + "rounded-b-md rounded-t-none border border-border/60 bg-muted/15 p-4", | |
| 157 | + )} | |
| 158 | + > | |
| 159 | + {method && !isOAuth && method.placements.length > 0 && !affix ? ( | |
| 160 | + <div className="flex flex-wrap gap-x-3.5 gap-y-1"> | |
| 161 | + {method.placements.map((placement, index) => ( | |
| 162 | + <PlacementLine key={index} placement={placement} /> | |
| 163 | + ))} | |
| 164 | + </div> | |
| 165 | + ) : null} | |
| 166 | + | |
| 167 | + <div className="space-y-2"> | |
| 168 | + <StepHeader label={isOAuth ? "OAuth app" : "Credential"} /> | |
| 169 | + | |
| 170 | + {isOAuth ? ( | |
| 171 | + <div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-3"> | |
| 172 | + <p className="text-sm font-medium">No app for {props.integrationName} yet</p> | |
| 173 | + <p className="text-xs text-muted-foreground"> | |
| 174 | + None of your registered apps target this integration's OAuth endpoint. | |
| 175 | + Register one to connect. | |
| 176 | + </p> | |
| 177 | + <div className="flex flex-wrap items-center gap-2 pt-1"> | |
| 178 | + <Button type="button" size="sm"> | |
| 179 | + Register app | |
| 180 | + </Button> | |
| 181 | + </div> | |
| 182 | + </div> | |
| 183 | + ) : affix ? ( | |
| 184 | + <div className="flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-input bg-transparent font-mono text-sm shadow-xs transition-colors focus-within:border-ring dark:bg-input/30"> | |
| 185 | + <span className="flex min-w-0 max-w-[60%] select-none items-center border-r border-input bg-muted/40 px-3 text-muted-foreground/70"> | |
| 186 | + <span className="overflow-hidden text-ellipsis whitespace-pre"> | |
| 187 | + {affix} | |
| 188 | + </span> | |
| 189 | + </span> | |
| 190 | + {/* oxlint-disable-next-line react/forbid-elements */} | |
| 191 | + <input | |
| 192 | + type={revealed ? "text" : "password"} | |
| 193 | + autoComplete="off" | |
| 194 | + aria-label="API key" | |
| 195 | + value={secret} | |
| 196 | + onChange={(event) => setSecret(event.target.value)} | |
| 197 | + className="min-w-0 flex-1 bg-transparent px-3 outline-none" | |
| 198 | + /> | |
| 199 | + <Button | |
| 200 | + type="button" | |
| 201 | + variant="ghost" | |
| 202 | + size="icon-sm" | |
| 203 | + aria-label="Show key" | |
| 204 | + aria-pressed={revealed} | |
| 205 | + className="shrink-0 text-muted-foreground" | |
| 206 | + onClick={() => setRevealed((value) => !value)} | |
| 207 | + > | |
| 208 | + {revealed ? <EyeOffIcon /> : <EyeIcon />} | |
| 209 | + </Button> | |
| 210 | + </div> | |
| 211 | + ) : ( | |
| 212 | + <div className="flex items-center gap-1"> | |
| 213 | + <Input | |
| 214 | + type={revealed ? "text" : "password"} | |
| 215 | + aria-label="API key" | |
| 216 | + value={secret} | |
| 217 | + onChange={(event) => setSecret((event.target as HTMLInputElement).value)} | |
| 218 | + className="font-mono" | |
| 219 | + /> | |
| 220 | + <Button | |
| 221 | + type="button" | |
| 222 | + variant="ghost" | |
| 223 | + size="icon-sm" | |
| 224 | + aria-label="Show key" | |
| 225 | + aria-pressed={revealed} | |
| 226 | + className="shrink-0 text-muted-foreground" | |
| 227 | + onClick={() => setRevealed((value) => !value)} | |
| 228 | + > | |
| 229 | + {revealed ? <EyeOffIcon /> : <EyeIcon />} | |
| 230 | + </Button> | |
| 231 | + </div> | |
| 232 | + )} | |
| 233 | + </div> | |
| 234 | + | |
| 235 | + {!isOAuth && ( | |
| 236 | + <div className="space-y-1.5"> | |
| 237 | + <StepHeader | |
| 238 | + label="Check the key works" | |
| 239 | + hint="runs one read-only call with your key, saved as this integration's health check" | |
| 240 | + /> | |
| 241 | + <div className="space-y-2 rounded-md border border-border/60 bg-background px-3 py-3"> | |
| 242 | + <Label className="text-xs text-muted-foreground">Operation</Label> | |
| 243 | + <NativeSelect size="sm" defaultValue="" aria-label="Health check operation"> | |
| 244 | + <NativeSelectOption value="">Pick an operation…</NativeSelectOption> | |
| 245 | + <NativeSelectOption value="users_me">users_me</NativeSelectOption> | |
| 246 | + <NativeSelectOption value="organizations_list"> | |
| 247 | + organizations_list | |
| 248 | + </NativeSelectOption> | |
| 249 | + <NativeSelectOption value="projects_list">projects_list</NativeSelectOption> | |
| 250 | + </NativeSelect> | |
| 251 | + <Button type="button" variant="outline" size="sm" disabled={!secret}> | |
| 252 | + Check | |
| 253 | + </Button> | |
| 254 | + </div> | |
| 255 | + </div> | |
| 256 | + )} | |
| 257 | + </TabsContent> | |
| 258 | + </Tabs> | |
| 259 | + )} | |
| 260 | + | |
| 261 | + {showPlaceStep && ( | |
| 262 | + <> | |
| 263 | + <div className="space-y-2"> | |
| 264 | + <StepHeader label="Display name" hint="how you'll tell accounts apart" /> | |
| 265 | + <Input | |
| 266 | + id="connection-name" | |
| 267 | + placeholder={derivedName} | |
| 268 | + value={label} | |
| 269 | + onChange={(event) => setLabel((event.target as HTMLInputElement).value)} | |
| 270 | + /> | |
| 271 | + <p className="text-xs text-muted-foreground"> | |
| 272 | + This connection will be callable as{" "} | |
| 273 | + <span className="font-mono text-foreground">{callableName}</span>. | |
| 274 | + </p> | |
| 275 | + </div> | |
| 276 | + | |
| 277 | + <div className="space-y-2"> | |
| 278 | + <StepHeader label="Connection saved to" /> | |
| 279 | + <NativeSelect | |
| 280 | + value={owner} | |
| 281 | + aria-label="Saved to" | |
| 282 | + onChange={(event) => setOwner(event.target.value === "org" ? "org" : "user")} | |
| 283 | + > | |
| 284 | + <NativeSelectOption value="user">Personal</NativeSelectOption> | |
| 285 | + <NativeSelectOption value="org">Workspace</NativeSelectOption> | |
| 286 | + </NativeSelect> | |
| 287 | + </div> | |
| 288 | + </> | |
| 289 | + )} | |
| 290 | + </div> | |
| 291 | + | |
| 292 | + <DialogFooter> | |
| 293 | + <Button type="button" variant="ghost" onClick={() => props.onOpenChange(false)}> | |
| 294 | + {isOAuth ? "Close" : "Cancel"} | |
| 295 | + </Button> | |
| 296 | + {isOAuth ? ( | |
| 297 | + <Button type="button" disabled> | |
| 298 | + {props.stuckConnecting ? "Connecting…" : "Connect with OAuth"} | |
| 299 | + </Button> | |
| 300 | + ) : wizardStep === "validate" ? ( | |
| 301 | + <Button type="button" onClick={() => setWizardStep("place")}> | |
| 302 | + Continue | |
| 303 | + </Button> | |
| 304 | + ) : ( | |
| 305 | + <> | |
| 306 | + <Button type="button" variant="ghost" onClick={() => setWizardStep("validate")}> | |
| 307 | + Back | |
| 308 | + </Button> | |
| 309 | + <Button type="button" onClick={props.onAdded}> | |
| 310 | + Add connection | |
| 311 | + </Button> | |
| 312 | + </> | |
| 313 | + )} | |
| 314 | + </DialogFooter> | |
| 315 | + </DialogContent> | |
| 316 | + </Dialog> | |
| 317 | + ); | |
| 318 | +} | |
| @@ -0,0 +1,261 @@ | ||
| 1 | +// Reproduction of packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx | |
| 2 | +// in its post-analyze state, plus the AuthMethodListEditor block from | |
| 3 | +// packages/react/src/components/auth-method-list-editor.tsx. | |
| 4 | +// | |
| 5 | +// The locked "Method 1" panel is reproduced exactly: LockIcon, muted fill, | |
| 6 | +// `cursor-not-allowed select-none`, and a masked `Authorization: Bearer ••••••` | |
| 7 | +// placement line. It is a read-only echo of the spec's declared auth scheme — | |
| 8 | +// it takes no input — but it is the only credential-shaped thing on the page, | |
| 9 | +// and it appears at the moment the user has just copied an API key. | |
| 10 | + | |
| 11 | +import { useState } from "react"; | |
| 12 | +import { LockIcon, XIcon, ChevronDownIcon } from "lucide-react"; | |
| 13 | +import { Button } from "@executor-js/react/components/button"; | |
| 14 | +import { Input } from "@executor-js/react/components/input"; | |
| 15 | +import { Label } from "@executor-js/react/components/label"; | |
| 16 | +import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; | |
| 17 | +import type { DemoPreset } from "../fixtures"; | |
| 18 | + | |
| 19 | +function PlacementLine(props: { | |
| 20 | + readonly carrier: "header" | "query"; | |
| 21 | + readonly name: string; | |
| 22 | + readonly prefix: string; | |
| 23 | +}) { | |
| 24 | + const lead = props.carrier === "header" ? `${props.name}: ` : `?${props.name}=`; | |
| 25 | + return ( | |
| 26 | + <span className="wrap-anywhere whitespace-pre-wrap font-mono text-xs text-muted-foreground"> | |
| 27 | + {lead} | |
| 28 | + {props.prefix ? <span className="text-muted-foreground/60">{props.prefix}</span> : null} | |
| 29 | + <span className="tracking-widest text-primary">••••••</span> | |
| 30 | + </span> | |
| 31 | + ); | |
| 32 | +} | |
| 33 | + | |
| 34 | +function FieldLabel(props: { readonly children: React.ReactNode }) { | |
| 35 | + return ( | |
| 36 | + <Label className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> | |
| 37 | + {props.children} | |
| 38 | + </Label> | |
| 39 | + ); | |
| 40 | +} | |
| 41 | + | |
| 42 | +export function AddOpenApiPage(props: { | |
| 43 | + readonly preset: DemoPreset | null; | |
| 44 | + readonly onCancel: () => void; | |
| 45 | + readonly onComplete: () => void; | |
| 46 | +}) { | |
| 47 | + const [adding, setAdding] = useState(false); | |
| 48 | + const [overridesOpen, setOverridesOpen] = useState(false); | |
| 49 | + const [healthCheckOpen, setHealthCheckOpen] = useState(false); | |
| 50 | + const name = props.preset?.name ?? "API"; | |
| 51 | + const slug = props.preset?.id ?? "api"; | |
| 52 | + | |
| 53 | + return ( | |
| 54 | + <div className="relative min-h-0 flex-1 overflow-y-auto"> | |
| 55 | + <div className="mx-auto flex min-h-full max-w-4xl flex-col gap-6 px-6 py-10 lg:px-10 lg:py-14"> | |
| 56 | + <div> | |
| 57 | + <h1 className="text-xl font-semibold text-foreground">Add OpenAPI integration</h1> | |
| 58 | + </div> | |
| 59 | + | |
| 60 | + {/* Details — OpenApiIntegrationDetailsFields */} | |
| 61 | + <CardStack> | |
| 62 | + <CardStackContent className="border-t-0"> | |
| 63 | + <div className="flex items-start gap-3 px-4 py-4"> | |
| 64 | + {props.preset?.icon ? ( | |
| 65 | + <img src={props.preset.icon} alt="" className="size-8 shrink-0 object-contain" /> | |
| 66 | + ) : ( | |
| 67 | + <span className="size-8 shrink-0 rounded-md bg-muted" /> | |
| 68 | + )} | |
| 69 | + <div className="min-w-0"> | |
| 70 | + <p className="text-sm font-medium text-foreground">{name}</p> | |
| 71 | + <p className="text-xs text-muted-foreground">1.0.0 · 214 operations · 31 tags</p> | |
| 72 | + </div> | |
| 73 | + </div> | |
| 74 | + <div className="space-y-4 px-4 pb-4"> | |
| 75 | + <div className="space-y-2"> | |
| 76 | + <FieldLabel>Integration ID</FieldLabel> | |
| 77 | + <Input defaultValue={slug} className="font-mono text-sm" /> | |
| 78 | + </div> | |
| 79 | + <div className="space-y-2"> | |
| 80 | + <FieldLabel>Display name</FieldLabel> | |
| 81 | + <Input defaultValue={name} /> | |
| 82 | + </div> | |
| 83 | + <div className="space-y-2"> | |
| 84 | + <FieldLabel>Description</FieldLabel> | |
| 85 | + <Input defaultValue={props.preset?.summary ?? ""} /> | |
| 86 | + </div> | |
| 87 | + <div className="space-y-2"> | |
| 88 | + <FieldLabel>Base URL override (optional)</FieldLabel> | |
| 89 | + <Input placeholder="https://api.example.com" className="font-mono text-sm" /> | |
| 90 | + <p className="text-[11px] text-muted-foreground"> | |
| 91 | + Overrides the spec's servers; leave empty to choose the server (and | |
| 92 | + variables) per tool call. | |
| 93 | + </p> | |
| 94 | + </div> | |
| 95 | + <div className="space-y-2"> | |
| 96 | + <FieldLabel>OpenAPI spec</FieldLabel> | |
| 97 | + <Input defaultValue={props.preset?.url ?? ""} className="font-mono text-sm" /> | |
| 98 | + </div> | |
| 99 | + </div> | |
| 100 | + </CardStackContent> | |
| 101 | + </CardStack> | |
| 102 | + | |
| 103 | + {/* SpecOverridesEditor — collapsed */} | |
| 104 | + <section className="space-y-2"> | |
| 105 | + {/* oxlint-disable-next-line react/forbid-elements */} | |
| 106 | + <button | |
| 107 | + type="button" | |
| 108 | + onClick={() => setOverridesOpen((open) => !open)} | |
| 109 | + className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" | |
| 110 | + > | |
| 111 | + Spec overrides (optional) | |
| 112 | + <ChevronDownIcon className={overridesOpen ? "size-3.5 rotate-180" : "size-3.5"} /> | |
| 113 | + </button> | |
| 114 | + {overridesOpen && ( | |
| 115 | + <div className="rounded-lg border border-border/60 bg-muted/20 p-3 font-mono text-xs text-muted-foreground"> | |
| 116 | + [] | |
| 117 | + </div> | |
| 118 | + )} | |
| 119 | + </section> | |
| 120 | + | |
| 121 | + {/* AuthMethodListEditor */} | |
| 122 | + <section className="space-y-3"> | |
| 123 | + <div className="flex items-center justify-between"> | |
| 124 | + <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> | |
| 125 | + Authentication | |
| 126 | + </h3> | |
| 127 | + <Button type="button" variant="outline" size="sm"> | |
| 128 | + Add method | |
| 129 | + </Button> | |
| 130 | + </div> | |
| 131 | + | |
| 132 | + <div className="flex flex-col gap-3"> | |
| 133 | + <div className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"> | |
| 134 | + <div className="flex items-center justify-between"> | |
| 135 | + <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> | |
| 136 | + <LockIcon className="size-3 shrink-0" aria-hidden /> | |
| 137 | + <span>Method 1</span> | |
| 138 | + </span> | |
| 139 | + <Button | |
| 140 | + type="button" | |
| 141 | + variant="ghost" | |
| 142 | + size="icon-sm" | |
| 143 | + aria-label="Remove method" | |
| 144 | + className="text-muted-foreground hover:text-foreground" | |
| 145 | + > | |
| 146 | + <XIcon /> | |
| 147 | + </Button> | |
| 148 | + </div> | |
| 149 | + <div className="space-y-2"> | |
| 150 | + <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground"> | |
| 151 | + API key | |
| 152 | + </p> | |
| 153 | + <div | |
| 154 | + aria-disabled | |
| 155 | + className="cursor-not-allowed select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground" | |
| 156 | + > | |
| 157 | + <div className="flex flex-wrap gap-x-4 gap-y-1"> | |
| 158 | + <PlacementLine carrier="header" name="Authorization" prefix="Bearer " /> | |
| 159 | + </div> | |
| 160 | + </div> | |
| 161 | + <p className="text-[11px] text-muted-foreground"> | |
| 162 | + Pulled from spec. Remove to override. | |
| 163 | + </p> | |
| 164 | + </div> | |
| 165 | + </div> | |
| 166 | + | |
| 167 | + <div className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"> | |
| 168 | + <div className="flex items-center justify-between"> | |
| 169 | + <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> | |
| 170 | + <LockIcon className="size-3 shrink-0" aria-hidden /> | |
| 171 | + <span>Method 2</span> | |
| 172 | + </span> | |
| 173 | + <Button | |
| 174 | + type="button" | |
| 175 | + variant="ghost" | |
| 176 | + size="icon-sm" | |
| 177 | + aria-label="Remove method" | |
| 178 | + className="text-muted-foreground hover:text-foreground" | |
| 179 | + > | |
| 180 | + <XIcon /> | |
| 181 | + </Button> | |
| 182 | + </div> | |
| 183 | + <div className="space-y-2"> | |
| 184 | + <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground"> | |
| 185 | + OAuth | |
| 186 | + </p> | |
| 187 | + <div | |
| 188 | + aria-disabled | |
| 189 | + className="cursor-not-allowed select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground" | |
| 190 | + > | |
| 191 | + <div className="flex gap-2 text-xs"> | |
| 192 | + <span className="w-20 shrink-0 text-muted-foreground">Authorize</span> | |
| 193 | + <span className="break-all font-mono text-foreground/80"> | |
| 194 | + https://us.posthog.com/oauth/authorize | |
| 195 | + </span> | |
| 196 | + </div> | |
| 197 | + <div className="flex gap-2 text-xs"> | |
| 198 | + <span className="w-20 shrink-0 text-muted-foreground">Token</span> | |
| 199 | + <span className="break-all font-mono text-foreground/80"> | |
| 200 | + https://us.posthog.com/oauth/token | |
| 201 | + </span> | |
| 202 | + </div> | |
| 203 | + </div> | |
| 204 | + <p className="text-[11px] text-muted-foreground"> | |
| 205 | + Pulled from spec. Remove to override. | |
| 206 | + </p> | |
| 207 | + </div> | |
| 208 | + </div> | |
| 209 | + </div> | |
| 210 | + | |
| 211 | + <p className="text-[11px] text-muted-foreground"> | |
| 212 | + Every method here is registered with the integration. Connect an account from the | |
| 213 | + integration page after adding. | |
| 214 | + </p> | |
| 215 | + </section> | |
| 216 | + | |
| 217 | + {/* AddOpenApiHealthCheckSection */} | |
| 218 | + <section className="space-y-2"> | |
| 219 | + {/* oxlint-disable-next-line react/forbid-elements */} | |
| 220 | + <button | |
| 221 | + type="button" | |
| 222 | + onClick={() => setHealthCheckOpen((open) => !open)} | |
| 223 | + className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" | |
| 224 | + > | |
| 225 | + Health check (optional) | |
| 226 | + <ChevronDownIcon className={healthCheckOpen ? "size-3.5 rotate-180" : "size-3.5"} /> | |
| 227 | + </button> | |
| 228 | + {healthCheckOpen && ( | |
| 229 | + <div className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"> | |
| 230 | + <FieldLabel>Operation</FieldLabel> | |
| 231 | + <Input placeholder="Pick an operation to probe…" /> | |
| 232 | + </div> | |
| 233 | + )} | |
| 234 | + </section> | |
| 235 | + | |
| 236 | + {/* FloatActions */} | |
| 237 | + <CardStack className="sticky bottom-4 left-0 right-0 mt-auto w-full transform-gpu shadow-lg"> | |
| 238 | + <div className="flex items-center justify-end gap-3 px-4 py-3"> | |
| 239 | + <Button variant="ghost" onClick={props.onCancel} disabled={adding}> | |
| 240 | + Cancel | |
| 241 | + </Button> | |
| 242 | + <Button | |
| 243 | + loading={adding} | |
| 244 | + onClick={() => { | |
| 245 | + setAdding(true); | |
| 246 | + // The real add is a spec parse + persist round trip; the button | |
| 247 | + // spins for as long as it takes, with nothing else to do. | |
| 248 | + setTimeout(() => { | |
| 249 | + setAdding(false); | |
| 250 | + props.onComplete(); | |
| 251 | + }, 1600); | |
| 252 | + }} | |
| 253 | + > | |
| 254 | + Add integration | |
| 255 | + </Button> | |
| 256 | + </div> | |
| 257 | + </CardStack> | |
| 258 | + </div> | |
| 259 | + </div> | |
| 260 | + ); | |
| 261 | +} | |
| @@ -0,0 +1,279 @@ | ||
| 1 | +// Reproduction of the ConnectDialog in packages/react/src/pages/integrations.tsx. | |
| 2 | +// | |
| 3 | +// Faithful to the parts that shape the experience: one input that silently | |
| 4 | +// switches between URL-detect and preset-filter, plugin chips above the list, | |
| 5 | +// a fixed 16rem scroll window over ~30 curated presets in plugin order, and | |
| 6 | +// the integrations.sh long tail that only appears once you have typed two | |
| 7 | +// characters. Picking anything leaves the dialog for a full-page form. | |
| 8 | + | |
| 9 | +import { useEffect, useMemo, useRef, useState } from "react"; | |
| 10 | +import { Button } from "@executor-js/react/components/button"; | |
| 11 | +import { Input } from "@executor-js/react/components/input"; | |
| 12 | +import { Badge } from "@executor-js/react/components/badge"; | |
| 13 | +import { Skeleton } from "@executor-js/react/components/skeleton"; | |
| 14 | +import { | |
| 15 | + Dialog, | |
| 16 | + DialogContent, | |
| 17 | + DialogDescription, | |
| 18 | + DialogHeader, | |
| 19 | + DialogTitle, | |
| 20 | +} from "@executor-js/react/components/dialog"; | |
| 21 | +import { | |
| 22 | + CardStack, | |
| 23 | + CardStackContent, | |
| 24 | + CardStackEntry, | |
| 25 | + CardStackEntryActions, | |
| 26 | + CardStackEntryContent, | |
| 27 | + CardStackEntryDescription, | |
| 28 | + CardStackEntryTitle, | |
| 29 | +} from "@executor-js/react/components/card-stack"; | |
| 30 | +import { | |
| 31 | + catalogLogoUrl, | |
| 32 | + curatedPresets, | |
| 33 | + integrationPlugins, | |
| 34 | + searchCatalog, | |
| 35 | + type CatalogEntry, | |
| 36 | + type DemoPreset, | |
| 37 | + type PluginKey, | |
| 38 | +} from "../fixtures"; | |
| 39 | + | |
| 40 | +const CATALOG_KIND_LABEL: Record<string, string> = { | |
| 41 | + mcp: "MCP", | |
| 42 | + openapi: "OpenAPI", | |
| 43 | + graphql: "GraphQL", | |
| 44 | +}; | |
| 45 | + | |
| 46 | +const looksLikeUrl = (raw: string): boolean => { | |
| 47 | + const v = raw.trim(); | |
| 48 | + if (v.length === 0) return false; | |
| 49 | + if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true; | |
| 50 | + if (v.includes("/")) return true; | |
| 51 | + if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true; | |
| 52 | + return false; | |
| 53 | +}; | |
| 54 | + | |
| 55 | +const SEARCH_DEBOUNCE_MS = 250; | |
| 56 | +const MIN_QUERY_LENGTH = 2; | |
| 57 | + | |
| 58 | +function useCatalogSearch(rawQuery: string) { | |
| 59 | + const query = rawQuery.trim().toLowerCase(); | |
| 60 | + const [state, setState] = useState<{ | |
| 61 | + readonly entries: readonly CatalogEntry[]; | |
| 62 | + readonly loading: boolean; | |
| 63 | + }>({ entries: [], loading: false }); | |
| 64 | + const generation = useRef(0); | |
| 65 | + | |
| 66 | + useEffect(() => { | |
| 67 | + const requestId = ++generation.current; | |
| 68 | + if (query.length < MIN_QUERY_LENGTH) { | |
| 69 | + setState({ entries: [], loading: false }); | |
| 70 | + return; | |
| 71 | + } | |
| 72 | + setState((previous) => ({ ...previous, loading: true })); | |
| 73 | + const timer = setTimeout(() => { | |
| 74 | + void searchCatalog(query).then((entries) => { | |
| 75 | + if (generation.current !== requestId) return; | |
| 76 | + setState({ entries, loading: false }); | |
| 77 | + }); | |
| 78 | + }, SEARCH_DEBOUNCE_MS); | |
| 79 | + return () => clearTimeout(timer); | |
| 80 | + }, [query]); | |
| 81 | + | |
| 82 | + return state; | |
| 83 | +} | |
| 84 | + | |
| 85 | +export function ConnectDialog(props: { | |
| 86 | + readonly open: boolean; | |
| 87 | + readonly onOpenChange: (open: boolean) => void; | |
| 88 | + readonly onPickPreset: (preset: DemoPreset) => void; | |
| 89 | + readonly onPickPlugin: (pluginKey: PluginKey) => void; | |
| 90 | + readonly onPickCatalogEntry: (entry: CatalogEntry) => void; | |
| 91 | +}) { | |
| 92 | + const [query, setQuery] = useState(""); | |
| 93 | + const isUrl = looksLikeUrl(query); | |
| 94 | + const presetSearch = isUrl ? "" : query; | |
| 95 | + | |
| 96 | + const filtered = useMemo(() => { | |
| 97 | + const q = presetSearch.trim().toLowerCase(); | |
| 98 | + if (q.length === 0) return curatedPresets; | |
| 99 | + return curatedPresets.filter(({ name, summary, pluginLabel }) => | |
| 100 | + `${name} ${summary} ${pluginLabel}`.toLowerCase().includes(q), | |
| 101 | + ); | |
| 102 | + }, [presetSearch]); | |
| 103 | + | |
| 104 | + const catalog = useCatalogSearch(presetSearch); | |
| 105 | + const showCatalogSection = | |
| 106 | + presetSearch.trim().length > 0 && (catalog.entries.length > 0 || catalog.loading); | |
| 107 | + | |
| 108 | + const close = () => { | |
| 109 | + setQuery(""); | |
| 110 | + props.onOpenChange(false); | |
| 111 | + }; | |
| 112 | + | |
| 113 | + return ( | |
| 114 | + <Dialog | |
| 115 | + open={props.open} | |
| 116 | + onOpenChange={(open: boolean) => { | |
| 117 | + if (!open) close(); | |
| 118 | + else props.onOpenChange(open); | |
| 119 | + }} | |
| 120 | + > | |
| 121 | + <DialogContent className="sm:max-w-[560px]"> | |
| 122 | + <DialogHeader> | |
| 123 | + <DialogTitle>Connect an integration</DialogTitle> | |
| 124 | + <DialogDescription> | |
| 125 | + Search the preset library, or paste a URL to auto-detect. | |
| 126 | + </DialogDescription> | |
| 127 | + </DialogHeader> | |
| 128 | + | |
| 129 | + <div className="flex min-w-0 flex-col gap-5"> | |
| 130 | + <div className="flex flex-col gap-2"> | |
| 131 | + <div className="flex gap-2"> | |
| 132 | + <Input | |
| 133 | + type="text" | |
| 134 | + value={query} | |
| 135 | + onChange={(event) => setQuery((event.target as HTMLInputElement).value)} | |
| 136 | + placeholder="Search or paste a URL…" | |
| 137 | + className="flex-1" | |
| 138 | + /> | |
| 139 | + {isUrl && <Button disabled={query.trim().length === 0}>Detect</Button>} | |
| 140 | + </div> | |
| 141 | + </div> | |
| 142 | + | |
| 143 | + <div className="flex flex-col gap-2"> | |
| 144 | + <p className="text-xs font-medium text-foreground/80">Or add manually</p> | |
| 145 | + <div className="flex flex-wrap gap-2"> | |
| 146 | + {integrationPlugins.map((plugin) => ( | |
| 147 | + // oxlint-disable-next-line react/forbid-elements | |
| 148 | + <button | |
| 149 | + key={plugin.key} | |
| 150 | + type="button" | |
| 151 | + onClick={() => { | |
| 152 | + close(); | |
| 153 | + props.onPickPlugin(plugin.key); | |
| 154 | + }} | |
| 155 | + className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted" | |
| 156 | + > | |
| 157 | + {plugin.label} | |
| 158 | + </button> | |
| 159 | + ))} | |
| 160 | + </div> | |
| 161 | + </div> | |
| 162 | + | |
| 163 | + <div className="flex min-w-0 flex-col gap-2"> | |
| 164 | + <p className="text-xs font-medium text-foreground/80">Popular integrations</p> | |
| 165 | + <CardStack className="min-w-0"> | |
| 166 | + <CardStackContent className="h-64 overflow-y-auto"> | |
| 167 | + {filtered.length === 0 && !showCatalogSection ? ( | |
| 168 | + <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-6 text-center"> | |
| 169 | + <p className="text-sm text-muted-foreground">No matching integrations</p> | |
| 170 | + <p className="text-xs text-muted-foreground/70"> | |
| 171 | + Paste a URL above to auto-detect, or pick an integration type manually. | |
| 172 | + </p> | |
| 173 | + </div> | |
| 174 | + ) : ( | |
| 175 | + filtered.map((preset) => ( | |
| 176 | + <CardStackEntry key={`${preset.pluginKey}-${preset.id}`} asChild> | |
| 177 | + {/* oxlint-disable-next-line react/forbid-elements */} | |
| 178 | + <button | |
| 179 | + type="button" | |
| 180 | + className="w-full text-left" | |
| 181 | + onClick={() => { | |
| 182 | + close(); | |
| 183 | + props.onPickPreset(preset); | |
| 184 | + }} | |
| 185 | + > | |
| 186 | + <span className="flex size-8 shrink-0 items-center justify-center"> | |
| 187 | + {preset.icon ? ( | |
| 188 | + <img | |
| 189 | + src={preset.icon} | |
| 190 | + alt="" | |
| 191 | + className="size-5 object-contain" | |
| 192 | + loading="lazy" | |
| 193 | + /> | |
| 194 | + ) : ( | |
| 195 | + <svg viewBox="0 0 16 16" className="size-3.5" fill="none"> | |
| 196 | + <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" /> | |
| 197 | + </svg> | |
| 198 | + )} | |
| 199 | + </span> | |
| 200 | + <CardStackEntryContent> | |
| 201 | + <CardStackEntryTitle>{preset.name}</CardStackEntryTitle> | |
| 202 | + <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription> | |
| 203 | + </CardStackEntryContent> | |
| 204 | + <CardStackEntryActions> | |
| 205 | + <Badge variant="secondary">{preset.pluginLabel}</Badge> | |
| 206 | + </CardStackEntryActions> | |
| 207 | + </button> | |
| 208 | + </CardStackEntry> | |
| 209 | + )) | |
| 210 | + )} | |
| 211 | + | |
| 212 | + {showCatalogSection && ( | |
| 213 | + <> | |
| 214 | + {catalog.entries.map((entry) => ( | |
| 215 | + <CardStackEntry key={`catalog-${entry.domain}`} asChild> | |
| 216 | + {/* oxlint-disable-next-line react/forbid-elements */} | |
| 217 | + <button | |
| 218 | + type="button" | |
| 219 | + className="w-full text-left" | |
| 220 | + onClick={() => { | |
| 221 | + close(); | |
| 222 | + props.onPickCatalogEntry(entry); | |
| 223 | + }} | |
| 224 | + > | |
| 225 | + <span className="flex size-8 shrink-0 items-center justify-center"> | |
| 226 | + <img | |
| 227 | + src={catalogLogoUrl(entry.domain, 10)} | |
| 228 | + alt="" | |
| 229 | + className="size-5 object-contain" | |
| 230 | + loading="lazy" | |
| 231 | + /> | |
| 232 | + </span> | |
| 233 | + <CardStackEntryContent> | |
| 234 | + <CardStackEntryTitle>{entry.domain}</CardStackEntryTitle> | |
| 235 | + <CardStackEntryDescription> | |
| 236 | + {entry.description} | |
| 237 | + </CardStackEntryDescription> | |
| 238 | + </CardStackEntryContent> | |
| 239 | + <CardStackEntryActions> | |
| 240 | + {entry.kinds.map((kind) => ( | |
| 241 | + <Badge key={kind} variant="secondary"> | |
| 242 | + {CATALOG_KIND_LABEL[kind]} | |
| 243 | + </Badge> | |
| 244 | + ))} | |
| 245 | + </CardStackEntryActions> | |
| 246 | + </button> | |
| 247 | + </CardStackEntry> | |
| 248 | + ))} | |
| 249 | + {catalog.loading && | |
| 250 | + catalog.entries.length === 0 && | |
| 251 | + Array.from({ length: 3 }).map((_, i) => ( | |
| 252 | + <div | |
| 253 | + key={`catalog-skeleton-${i}`} | |
| 254 | + className="flex items-center gap-3 px-4 py-3" | |
| 255 | + > | |
| 256 | + <Skeleton className="size-5 shrink-0 rounded-md" /> | |
| 257 | + <div className="flex min-w-0 flex-1 flex-col gap-1.5"> | |
| 258 | + <Skeleton | |
| 259 | + className="h-3.5" | |
| 260 | + style={{ width: `${35 + ((i * 13) % 25)}%` }} | |
| 261 | + /> | |
| 262 | + <Skeleton | |
| 263 | + className="h-3" | |
| 264 | + style={{ width: `${55 + ((i * 9) % 20)}%` }} | |
| 265 | + /> | |
| 266 | + </div> | |
| 267 | + <Skeleton className="h-5 w-14 rounded-full" /> | |
| 268 | + </div> | |
| 269 | + ))} | |
| 270 | + </> | |
| 271 | + )} | |
| 272 | + </CardStackContent> | |
| 273 | + </CardStack> | |
| 274 | + </div> | |
| 275 | + </div> | |
| 276 | + </DialogContent> | |
| 277 | + </Dialog> | |
| 278 | + ); | |
| 279 | +} | |
| @@ -0,0 +1,105 @@ | ||
| 1 | +// Reproduction of packages/react/src/pages/integration-detail.tsx plus the | |
| 2 | +// AccountsSection from packages/react/src/components/accounts-section.tsx. | |
| 3 | +// | |
| 4 | +// This is where a just-added integration lands. It has an Edit / Refresh / | |
| 5 | +// Delete header, an Accounts / Tools tab strip, a "Connections" heading, and a | |
| 6 | +// dashed empty card whose CTA is "Add connection" — the second add, for a thing | |
| 7 | +// the user did not know was separate from the first. | |
| 8 | + | |
| 9 | +import { useState } from "react"; | |
| 10 | +import { Button } from "@executor-js/react/components/button"; | |
| 11 | +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; | |
| 12 | +import type { DemoIntegration } from "../fixtures"; | |
| 13 | + | |
| 14 | +export function IntegrationDetailPage(props: { | |
| 15 | + readonly integration: DemoIntegration; | |
| 16 | + readonly onAddConnection: () => void; | |
| 17 | +}) { | |
| 18 | + const [tab, setTab] = useState<"accounts" | "tools">("accounts"); | |
| 19 | + | |
| 20 | + return ( | |
| 21 | + <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> | |
| 22 | + <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm"> | |
| 23 | + <div className="flex min-w-0 items-center gap-3"> | |
| 24 | + <h2 className="truncate text-sm font-semibold text-foreground"> | |
| 25 | + {props.integration.name} | |
| 26 | + </h2> | |
| 27 | + <span className="hidden text-xs tabular-nums text-muted-foreground sm:block"> | |
| 28 | + {props.integration.toolCount} tools | |
| 29 | + </span> | |
| 30 | + </div> | |
| 31 | + <div className="flex shrink-0 items-center gap-2"> | |
| 32 | + <Button variant="outline" size="sm"> | |
| 33 | + Edit | |
| 34 | + </Button> | |
| 35 | + <Button variant="outline" size="sm"> | |
| 36 | + Refresh | |
| 37 | + </Button> | |
| 38 | + <Button | |
| 39 | + variant="outline" | |
| 40 | + size="sm" | |
| 41 | + className="border-destructive/30 text-destructive hover:bg-destructive/10" | |
| 42 | + > | |
| 43 | + Delete | |
| 44 | + </Button> | |
| 45 | + </div> | |
| 46 | + </div> | |
| 47 | + | |
| 48 | + <Tabs | |
| 49 | + value={tab} | |
| 50 | + onValueChange={(value: string) => setTab(value === "tools" ? "tools" : "accounts")} | |
| 51 | + className="min-h-0 flex-1 gap-0 overflow-hidden" | |
| 52 | + > | |
| 53 | + <div className="shrink-0 border-b border-border/60 px-4 py-2"> | |
| 54 | + <TabsList variant="line"> | |
| 55 | + <TabsTrigger value="accounts">Accounts</TabsTrigger> | |
| 56 | + <TabsTrigger value="tools">Tools</TabsTrigger> | |
| 57 | + </TabsList> | |
| 58 | + </div> | |
| 59 | + | |
| 60 | + <TabsContent value="accounts" className="min-h-0 overflow-y-auto"> | |
| 61 | + <div className="mx-auto max-w-3xl space-y-8 px-6 py-8"> | |
| 62 | + <section className="space-y-3"> | |
| 63 | + <div className="flex items-center justify-between"> | |
| 64 | + <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> | |
| 65 | + Connections | |
| 66 | + </h3> | |
| 67 | + </div> | |
| 68 | + <div className="rounded-lg border border-dashed border-border/60 px-6 py-8 text-center"> | |
| 69 | + <p className="text-sm font-medium text-foreground">No connections yet</p> | |
| 70 | + <p className="mt-1 text-sm text-muted-foreground"> | |
| 71 | + Add a connection to make this integration's tools available. | |
| 72 | + </p> | |
| 73 | + <Button type="button" className="mt-4" size="sm" onClick={props.onAddConnection}> | |
| 74 | + Add connection | |
| 75 | + </Button> | |
| 76 | + </div> | |
| 77 | + </section> | |
| 78 | + </div> | |
| 79 | + </TabsContent> | |
| 80 | + | |
| 81 | + <TabsContent | |
| 82 | + value="tools" | |
| 83 | + className="flex min-h-0 flex-col overflow-hidden data-[state=inactive]:hidden" | |
| 84 | + > | |
| 85 | + <div className="flex min-h-0 flex-1 overflow-hidden"> | |
| 86 | + <div className="flex w-72 shrink-0 flex-col border-r border-border/60 p-3 lg:w-80 xl:w-[22rem]"> | |
| 87 | + <p className="px-2 py-1.5 text-xs text-muted-foreground">No tools yet</p> | |
| 88 | + </div> | |
| 89 | + <div className="flex min-w-0 flex-1 items-center justify-center"> | |
| 90 | + <div className="max-w-sm text-center"> | |
| 91 | + <p className="text-sm font-medium text-foreground">No tools yet</p> | |
| 92 | + <p className="mt-1.5 text-sm text-muted-foreground"> | |
| 93 | + Add a connection to unlock this integration's tools. | |
| 94 | + </p> | |
| 95 | + <Button type="button" size="sm" className="mt-4" onClick={props.onAddConnection}> | |
| 96 | + Add connection | |
| 97 | + </Button> | |
| 98 | + </div> | |
| 99 | + </div> | |
| 100 | + </div> | |
| 101 | + </TabsContent> | |
| 102 | + </Tabs> | |
| 103 | + </div> | |
| 104 | + ); | |
| 105 | +} | |
| @@ -0,0 +1,142 @@ | ||
| 1 | +// Reproduction of packages/react/src/pages/integrations.tsx — the screen that | |
| 2 | +// follows CLI setup. The "Connect an agent" card sits above the fold; finding | |
| 3 | +// an integration is a dialog behind one button in the header. | |
| 4 | + | |
| 5 | +import { PlusIcon } from "lucide-react"; | |
| 6 | +import { Button } from "@executor-js/react/components/button"; | |
| 7 | +import { PageContainer, PageHeader } from "@executor-js/react/components/page"; | |
| 8 | +import { | |
| 9 | + CardStack, | |
| 10 | + CardStackContent, | |
| 11 | + CardStackEntry, | |
| 12 | + CardStackEntryActions, | |
| 13 | + CardStackEntryContent, | |
| 14 | + CardStackEntryDescription, | |
| 15 | + CardStackEntryTitle, | |
| 16 | + CardStackHeader, | |
| 17 | +} from "@executor-js/react/components/card-stack"; | |
| 18 | +import { Badge } from "@executor-js/react/components/badge"; | |
| 19 | +import { Tabs, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; | |
| 20 | +import type { DemoIntegration } from "../fixtures"; | |
| 21 | + | |
| 22 | +function McpInstallCard() { | |
| 23 | + return ( | |
| 24 | + <CardStack> | |
| 25 | + <Tabs value="http"> | |
| 26 | + <CardStackHeader | |
| 27 | + className="items-start py-4" | |
| 28 | + rightSlot={ | |
| 29 | + <TabsList> | |
| 30 | + <TabsTrigger value="http">Remote HTTP</TabsTrigger> | |
| 31 | + <TabsTrigger value="stdio">Standard I/O</TabsTrigger> | |
| 32 | + </TabsList> | |
| 33 | + } | |
| 34 | + > | |
| 35 | + <div className="flex min-w-0 flex-col gap-0.5"> | |
| 36 | + <span className="text-sm font-semibold text-foreground">Connect an agent</span> | |
| 37 | + <span className="text-xs font-normal text-muted-foreground"> | |
| 38 | + Paste this into Claude Code, Cursor, or any MCP client, and your agent gets every tool | |
| 39 | + you connect here. | |
| 40 | + </span> | |
| 41 | + </div> | |
| 42 | + </CardStackHeader> | |
| 43 | + <CardStackContent> | |
| 44 | + <div className="px-4 pt-3 pb-3"> | |
| 45 | + <div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 font-mono text-xs text-foreground"> | |
| 46 | + npx add-mcp http://localhost:3123/mcp --transport http --name executor | |
| 47 | + </div> | |
| 48 | + <div className="mt-3 text-xs font-medium text-muted-foreground">Advanced ⌄</div> | |
| 49 | + </div> | |
| 50 | + <div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground"> | |
| 51 | + <span>Work with your agent</span> | |
| 52 | + <span className="rounded-md border border-border/60 px-1.5 py-0.5">Cursor</span> | |
| 53 | + <span className="rounded-md border border-border/60 px-1.5 py-0.5">Claude</span> | |
| 54 | + <span className="rounded-md border border-border/60 px-1.5 py-0.5">OpenCode</span> | |
| 55 | + <span>and more</span> | |
| 56 | + </div> | |
| 57 | + </CardStackContent> | |
| 58 | + </Tabs> | |
| 59 | + </CardStack> | |
| 60 | + ); | |
| 61 | +} | |
| 62 | + | |
| 63 | +export function IntegrationsPage(props: { | |
| 64 | + readonly integrations: readonly DemoIntegration[]; | |
| 65 | + readonly onConnect: () => void; | |
| 66 | + readonly onOpenIntegration: (slug: string) => void; | |
| 67 | +}) { | |
| 68 | + return ( | |
| 69 | + <PageContainer> | |
| 70 | + <PageHeader | |
| 71 | + title="Integrations" | |
| 72 | + description="Tool providers available in this workspace." | |
| 73 | + actions={ | |
| 74 | + <Button onClick={props.onConnect} size="sm" className="gap-1.5"> | |
| 75 | + <PlusIcon className="size-4" /> | |
| 76 | + Connect | |
| 77 | + </Button> | |
| 78 | + } | |
| 79 | + /> | |
| 80 | + | |
| 81 | + <div className="mb-8"> | |
| 82 | + <McpInstallCard /> | |
| 83 | + </div> | |
| 84 | + | |
| 85 | + <div className="mb-8 border-t border-border/50" /> | |
| 86 | + | |
| 87 | + {props.integrations.length === 0 ? ( | |
| 88 | + <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16"> | |
| 89 | + <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground"> | |
| 90 | + <PlusIcon className="size-5" /> | |
| 91 | + </div> | |
| 92 | + <p className="mb-1 text-[14px] font-medium text-foreground/70">No integrations yet</p> | |
| 93 | + <p className="mb-5 text-[13px] text-muted-foreground/60"> | |
| 94 | + Connect an integration to start curating tools. | |
| 95 | + </p> | |
| 96 | + <Button onClick={props.onConnect} size="sm" className="gap-1.5"> | |
| 97 | + <PlusIcon className="size-4" /> | |
| 98 | + Connect an integration | |
| 99 | + </Button> | |
| 100 | + </div> | |
| 101 | + ) : ( | |
| 102 | + <div className="mb-8 space-y-3"> | |
| 103 | + <CardStack searchable> | |
| 104 | + <CardStackContent> | |
| 105 | + {props.integrations.map((integration) => ( | |
| 106 | + <CardStackEntry key={integration.slug} asChild> | |
| 107 | + {/* oxlint-disable-next-line react/forbid-elements */} | |
| 108 | + <button | |
| 109 | + type="button" | |
| 110 | + className="w-full text-left" | |
| 111 | + onClick={() => props.onOpenIntegration(integration.slug)} | |
| 112 | + > | |
| 113 | + {integration.icon ? ( | |
| 114 | + <img | |
| 115 | + src={integration.icon} | |
| 116 | + alt="" | |
| 117 | + className="size-8 shrink-0 rounded-md object-contain" | |
| 118 | + /> | |
| 119 | + ) : ( | |
| 120 | + <span className="size-8 shrink-0 rounded-md bg-muted" /> | |
| 121 | + )} | |
| 122 | + <CardStackEntryContent> | |
| 123 | + <CardStackEntryTitle>{integration.name}</CardStackEntryTitle> | |
| 124 | + <CardStackEntryDescription>{integration.slug}</CardStackEntryDescription> | |
| 125 | + </CardStackEntryContent> | |
| 126 | + <CardStackEntryActions> | |
| 127 | + <Badge variant="outline"> | |
| 128 | + {integration.toolCount === 0 | |
| 129 | + ? "No connection" | |
| 130 | + : `${integration.toolCount} tools`} | |
| 131 | + </Badge> | |
| 132 | + </CardStackEntryActions> | |
| 133 | + </button> | |
| 134 | + </CardStackEntry> | |
| 135 | + ))} | |
| 136 | + </CardStackContent> | |
| 137 | + </CardStack> | |
| 138 | + </div> | |
| 139 | + )} | |
| 140 | + </PageContainer> | |
| 141 | + ); | |
| 142 | +} | |
| @@ -0,0 +1,229 @@ | ||
| 1 | +// Reworked: authenticating one account. | |
| 2 | +// | |
| 3 | +// The whole screen is built from the registry's own credential record, which | |
| 4 | +// integrations.sh already returns and the console currently discards: | |
| 5 | +// credentials[].label → what this key is called at the provider | |
| 6 | +// credentials[].generateUrl → the page that mints it, as a real link | |
| 7 | +// credentials[].setup → how to mint it, in the provider's own terms | |
| 8 | +// | |
| 9 | +// So the three questions the current modal leaves unanswered — what kind of key, | |
| 10 | +// where do I get one, where does it go — are answered on the screen that asks | |
| 11 | +// for it. The paste field is the first focusable thing and is never gated: a | |
| 12 | +// key already on the clipboard can go in immediately. | |
| 13 | + | |
| 14 | +import { useEffect, useState } from "react"; | |
| 15 | +import { ArrowUpRightIcon, EyeIcon, EyeOffIcon } from "lucide-react"; | |
| 16 | +import { Button } from "@executor-js/react/components/button"; | |
| 17 | +import { Label } from "@executor-js/react/components/label"; | |
| 18 | +import { | |
| 19 | + Dialog, | |
| 20 | + DialogContent, | |
| 21 | + DialogDescription, | |
| 22 | + DialogFooter, | |
| 23 | + DialogHeader, | |
| 24 | + DialogTitle, | |
| 25 | +} from "@executor-js/react/components/dialog"; | |
| 26 | +import { fetchSurfaceCredentials, type SurfaceCredential } from "../../fixtures"; | |
| 27 | + | |
| 28 | +/** The registry's setup text is markdown. Only links and bold appear in | |
| 29 | + * practice, so render those two and leave the rest as text rather than | |
| 30 | + * pulling in a markdown pipeline for a prototype. */ | |
| 31 | +function SetupText(props: { readonly text: string }) { | |
| 32 | + const nodes = props.text | |
| 33 | + .split(/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`)/g) | |
| 34 | + .filter((chunk) => chunk.length > 0) | |
| 35 | + .map((chunk, index) => { | |
| 36 | + const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(chunk); | |
| 37 | + if (link) { | |
| 38 | + return ( | |
| 39 | + <a | |
| 40 | + key={index} | |
| 41 | + href={link[2]} | |
| 42 | + target="_blank" | |
| 43 | + rel="noreferrer" | |
| 44 | + className="font-medium text-foreground underline underline-offset-2" | |
| 45 | + > | |
| 46 | + {link[1]} | |
| 47 | + </a> | |
| 48 | + ); | |
| 49 | + } | |
| 50 | + const bold = /^\*\*([^*]+)\*\*$/.exec(chunk); | |
| 51 | + if (bold) { | |
| 52 | + return ( | |
| 53 | + <span key={index} className="font-medium text-foreground"> | |
| 54 | + {bold[1]} | |
| 55 | + </span> | |
| 56 | + ); | |
| 57 | + } | |
| 58 | + const code = /^`([^`]+)`$/.exec(chunk); | |
| 59 | + if (code) { | |
| 60 | + return ( | |
| 61 | + <code key={index} className="font-mono text-[0.9em] text-foreground"> | |
| 62 | + {code[1]} | |
| 63 | + </code> | |
| 64 | + ); | |
| 65 | + } | |
| 66 | + return <span key={index}>{chunk}</span>; | |
| 67 | + }); | |
| 68 | + return <p className="text-xs leading-relaxed text-muted-foreground">{nodes}</p>; | |
| 69 | +} | |
| 70 | + | |
| 71 | +export function AuthenticateDialog(props: { | |
| 72 | + readonly open: boolean; | |
| 73 | + readonly onOpenChange: (open: boolean) => void; | |
| 74 | + readonly integrationName: string; | |
| 75 | + readonly domain: string; | |
| 76 | + readonly accountLabel: string; | |
| 77 | + readonly onAuthenticated: () => void; | |
| 78 | +}) { | |
| 79 | + const [credentials, setCredentials] = useState<readonly SurfaceCredential[] | null>(null); | |
| 80 | + const [selectedId, setSelectedId] = useState<string | null>(null); | |
| 81 | + const [secret, setSecret] = useState(""); | |
| 82 | + const [revealed, setRevealed] = useState(false); | |
| 83 | + const [submitting, setSubmitting] = useState(false); | |
| 84 | + | |
| 85 | + useEffect(() => { | |
| 86 | + let live = true; | |
| 87 | + void fetchSurfaceCredentials(props.domain).then((loaded) => { | |
| 88 | + if (!live) return; | |
| 89 | + setCredentials(loaded); | |
| 90 | + setSelectedId(loaded[0]?.id ?? null); | |
| 91 | + }); | |
| 92 | + return () => { | |
| 93 | + live = false; | |
| 94 | + }; | |
| 95 | + }, [props.domain]); | |
| 96 | + | |
| 97 | + const selected = credentials?.find((candidate) => candidate.id === selectedId) ?? null; | |
| 98 | + const isOAuth = selected?.type === "oauth2"; | |
| 99 | + const canSubmit = isOAuth || secret.trim().length > 0; | |
| 100 | + | |
| 101 | + return ( | |
| 102 | + <Dialog open={props.open} onOpenChange={props.onOpenChange}> | |
| 103 | + <DialogContent className="sm:max-w-lg"> | |
| 104 | + <DialogHeader> | |
| 105 | + <DialogTitle>Connect {props.integrationName}</DialogTitle> | |
| 106 | + <DialogDescription> | |
| 107 | + Signing in as <span className="font-mono text-foreground">{props.accountLabel}</span>. | |
| 108 | + </DialogDescription> | |
| 109 | + </DialogHeader> | |
| 110 | + | |
| 111 | + {credentials === null ? ( | |
| 112 | + <p className="py-6 text-sm text-muted-foreground"> | |
| 113 | + Checking how {props.domain} signs in… | |
| 114 | + </p> | |
| 115 | + ) : credentials.length === 0 ? ( | |
| 116 | + <p className="py-6 text-sm text-muted-foreground"> | |
| 117 | + The registry has no credential record for {props.domain} yet. | |
| 118 | + </p> | |
| 119 | + ) : ( | |
| 120 | + <div className="flex flex-col gap-4"> | |
| 121 | + {/* Only shown when the provider genuinely offers a choice. One way | |
| 122 | + in renders no picker at all. */} | |
| 123 | + {credentials.length > 1 && ( | |
| 124 | + <div className="flex gap-1.5"> | |
| 125 | + {credentials.map((credential) => ( | |
| 126 | + // oxlint-disable-next-line react/forbid-elements | |
| 127 | + <button | |
| 128 | + key={credential.id} | |
| 129 | + type="button" | |
| 130 | + onClick={() => setSelectedId(credential.id)} | |
| 131 | + className={ | |
| 132 | + credential.id === selectedId | |
| 133 | + ? "rounded-full border border-foreground/20 bg-foreground px-3 py-1 text-xs font-medium text-background" | |
| 134 | + : "rounded-full border border-border px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground" | |
| 135 | + } | |
| 136 | + > | |
| 137 | + {credential.label} | |
| 138 | + </button> | |
| 139 | + ))} | |
| 140 | + </div> | |
| 141 | + )} | |
| 142 | + | |
| 143 | + {isOAuth ? ( | |
| 144 | + <p className="text-xs leading-relaxed text-muted-foreground"> | |
| 145 | + {props.integrationName} signs in through your browser. Nothing to paste. | |
| 146 | + </p> | |
| 147 | + ) : ( | |
| 148 | + <> | |
| 149 | + <div className="space-y-2"> | |
| 150 | + <div className="flex items-center justify-between gap-3"> | |
| 151 | + <Label | |
| 152 | + htmlFor="credential" | |
| 153 | + className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground" | |
| 154 | + > | |
| 155 | + {selected?.label ?? "API key"} | |
| 156 | + </Label> | |
| 157 | + {selected?.generateUrl ? ( | |
| 158 | + <a | |
| 159 | + href={selected.generateUrl} | |
| 160 | + target="_blank" | |
| 161 | + rel="noreferrer" | |
| 162 | + className="flex items-center gap-1 text-xs font-medium text-foreground underline underline-offset-2" | |
| 163 | + > | |
| 164 | + Get a key | |
| 165 | + <ArrowUpRightIcon className="size-3" aria-hidden /> | |
| 166 | + </a> | |
| 167 | + ) : null} | |
| 168 | + </div> | |
| 169 | + <div className="flex items-center gap-1"> | |
| 170 | + {/* oxlint-disable-next-line react/forbid-elements */} | |
| 171 | + <input | |
| 172 | + id="credential" | |
| 173 | + type={revealed ? "text" : "password"} | |
| 174 | + autoComplete="off" | |
| 175 | + // The one thing this screen is for. | |
| 176 | + // oxlint-disable-next-line jsx_a11y/no-autofocus | |
| 177 | + autoFocus | |
| 178 | + value={secret} | |
| 179 | + onChange={(event) => setSecret(event.target.value)} | |
| 180 | + placeholder="Paste your key" | |
| 181 | + className="h-10 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 font-mono text-sm outline-none transition-colors focus:border-ring dark:bg-input/30" | |
| 182 | + /> | |
| 183 | + <Button | |
| 184 | + type="button" | |
| 185 | + variant="ghost" | |
| 186 | + size="icon-sm" | |
| 187 | + aria-label="Show key" | |
| 188 | + aria-pressed={revealed} | |
| 189 | + className="shrink-0 text-muted-foreground" | |
| 190 | + onClick={() => setRevealed((value) => !value)} | |
| 191 | + > | |
| 192 | + {revealed ? <EyeOffIcon /> : <EyeIcon />} | |
| 193 | + </Button> | |
| 194 | + </div> | |
| 195 | + </div> | |
| 196 | + | |
| 197 | + {selected?.setup ? ( | |
| 198 | + <div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3"> | |
| 199 | + <SetupText text={selected.setup} /> | |
| 200 | + </div> | |
| 201 | + ) : null} | |
| 202 | + </> | |
| 203 | + )} | |
| 204 | + </div> | |
| 205 | + )} | |
| 206 | + | |
| 207 | + <DialogFooter> | |
| 208 | + <Button type="button" variant="ghost" onClick={() => props.onOpenChange(false)}> | |
| 209 | + Cancel | |
| 210 | + </Button> | |
| 211 | + <Button | |
| 212 | + type="button" | |
| 213 | + disabled={!canSubmit || submitting} | |
| 214 | + loading={submitting} | |
| 215 | + onClick={() => { | |
| 216 | + setSubmitting(true); | |
| 217 | + setTimeout(() => { | |
| 218 | + setSubmitting(false); | |
| 219 | + props.onAuthenticated(); | |
| 220 | + }, 700); | |
| 221 | + }} | |
| 222 | + > | |
| 223 | + {isOAuth ? `Continue to ${props.integrationName}` : "Connect"} | |
| 224 | + </Button> | |
| 225 | + </DialogFooter> | |
| 226 | + </DialogContent> | |
| 227 | + </Dialog> | |
| 228 | + ); | |
| 229 | +} | |
| @@ -0,0 +1,234 @@ | ||
| 1 | +// Reworked: the picker. | |
| 2 | +// | |
| 3 | +// Not a dialog behind a header button — the page itself. Search is the first | |
| 4 | +// thing on it, categories are chips under that, and every row adds in place: | |
| 5 | +// the row flips to "Added", the list does not move, and you can keep adding. | |
| 6 | +// Nothing here asks for a spec URL, a base URL or an auth method, because the | |
| 7 | +// registry already knows all three. | |
| 8 | +// | |
| 9 | +// "Add custom" sits next to the search box at all times, and the empty-search | |
| 10 | +// state leads with it rather than apologising — a URL that isn't in the | |
| 11 | +// registry is a normal thing to have, not a failed search. | |
| 12 | + | |
| 13 | +import { useEffect, useMemo, useState } from "react"; | |
| 14 | +import { CheckIcon, PlusIcon, SearchIcon } from "lucide-react"; | |
| 15 | +import { Button } from "@executor-js/react/components/button"; | |
| 16 | +import { Input } from "@executor-js/react/components/input"; | |
| 17 | +import { Skeleton } from "@executor-js/react/components/skeleton"; | |
| 18 | +import { cn } from "@executor-js/react/lib/utils"; | |
| 19 | +import { CATEGORY_ORDER, loadCatalog, searchItems, type CatalogItem } from "../../catalog"; | |
| 20 | + | |
| 21 | +const SECTION_SIZE = 6; | |
| 22 | + | |
| 23 | +function AddButton(props: { readonly added: boolean; readonly onAdd: () => void }) { | |
| 24 | + if (props.added) { | |
| 25 | + return ( | |
| 26 | + <span className="flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-xs font-medium text-muted-foreground"> | |
| 27 | + <CheckIcon className="size-3.5" aria-hidden /> | |
| 28 | + Added | |
| 29 | + </span> | |
| 30 | + ); | |
| 31 | + } | |
| 32 | + return ( | |
| 33 | + <Button type="button" variant="outline" size="sm" className="shrink-0" onClick={props.onAdd}> | |
| 34 | + Add | |
| 35 | + </Button> | |
| 36 | + ); | |
| 37 | +} | |
| 38 | + | |
| 39 | +function CatalogRow(props: { | |
| 40 | + readonly item: CatalogItem; | |
| 41 | + readonly added: boolean; | |
| 42 | + readonly onAdd: () => void; | |
| 43 | + readonly onOpen: () => void; | |
| 44 | +}) { | |
| 45 | + return ( | |
| 46 | + <div | |
| 47 | + className={cn( | |
| 48 | + "flex items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors", | |
| 49 | + props.added ? "border-ring/40 bg-accent/40" : "border-transparent hover:bg-accent/40", | |
| 50 | + )} | |
| 51 | + > | |
| 52 | + {/* oxlint-disable-next-line react/forbid-elements */} | |
| 53 | + <button | |
| 54 | + type="button" | |
| 55 | + onClick={props.onOpen} | |
| 56 | + className="flex min-w-0 flex-1 items-center gap-3 text-left" | |
| 57 | + > | |
| 58 | + <img | |
| 59 | + src={props.item.icon} | |
| 60 | + alt="" | |
| 61 | + loading="lazy" | |
| 62 | + className="size-8 shrink-0 rounded-md object-contain" | |
| 63 | + /> | |
| 64 | + <span className="min-w-0 flex-1"> | |
| 65 | + <span className="block truncate text-sm font-medium text-foreground"> | |
| 66 | + {props.item.name} | |
| 67 | + </span> | |
| 68 | + <span className="block truncate text-xs text-muted-foreground"> | |
| 69 | + {props.item.description} | |
| 70 | + </span> | |
| 71 | + </span> | |
| 72 | + </button> | |
| 73 | + <AddButton added={props.added} onAdd={props.onAdd} /> | |
| 74 | + </div> | |
| 75 | + ); | |
| 76 | +} | |
| 77 | + | |
| 78 | +/** Offered whenever the query cannot be satisfied from the registry — either | |
| 79 | + * because it looks like a URL, or because nothing matched. */ | |
| 80 | +function CustomPrompt(props: { readonly query: string; readonly onAddCustom: () => void }) { | |
| 81 | + const looksLikeUrl = /^[a-z]+:\/\//i.test(props.query) || props.query.includes("/"); | |
| 82 | + return ( | |
| 83 | + <div className="flex items-center gap-3 rounded-lg border border-dashed border-border px-3 py-3"> | |
| 84 | + <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"> | |
| 85 | + <PlusIcon className="size-4" aria-hidden /> | |
| 86 | + </span> | |
| 87 | + <div className="min-w-0 flex-1"> | |
| 88 | + <p className="text-sm font-medium text-foreground"> | |
| 89 | + {looksLikeUrl ? "Add this URL directly" : "Not in the registry?"} | |
| 90 | + </p> | |
| 91 | + <p className="text-xs text-muted-foreground"> | |
| 92 | + Point executor at any MCP server, OpenAPI spec, or GraphQL endpoint. | |
| 93 | + </p> | |
| 94 | + </div> | |
| 95 | + <Button type="button" size="sm" className="shrink-0" onClick={props.onAddCustom}> | |
| 96 | + Add custom | |
| 97 | + </Button> | |
| 98 | + </div> | |
| 99 | + ); | |
| 100 | +} | |
| 101 | + | |
| 102 | +export function BrowsePage(props: { | |
| 103 | + readonly addedDomains: readonly string[]; | |
| 104 | + readonly onAdd: (item: CatalogItem) => void; | |
| 105 | + readonly onOpen: (item: CatalogItem) => void; | |
| 106 | + readonly onAddCustom: () => void; | |
| 107 | +}) { | |
| 108 | + const [items, setItems] = useState<readonly CatalogItem[] | null>(null); | |
| 109 | + const [query, setQuery] = useState(""); | |
| 110 | + const [category, setCategory] = useState<string>("All"); | |
| 111 | + | |
| 112 | + useEffect(() => { | |
| 113 | + let live = true; | |
| 114 | + void loadCatalog().then((loaded) => { | |
| 115 | + if (live) setItems(loaded); | |
| 116 | + }); | |
| 117 | + return () => { | |
| 118 | + live = false; | |
| 119 | + }; | |
| 120 | + }, []); | |
| 121 | + | |
| 122 | + const searched = useMemo(() => (items ? searchItems(items, query) : []), [items, query]); | |
| 123 | + const searching = query.trim().length > 0; | |
| 124 | + | |
| 125 | + const sections = useMemo(() => { | |
| 126 | + if (searching) return []; | |
| 127 | + const wanted = category === "All" ? CATEGORY_ORDER : [category]; | |
| 128 | + return wanted | |
| 129 | + .map((name) => ({ | |
| 130 | + name, | |
| 131 | + items: searched.filter((item) => item.category === name).slice(0, SECTION_SIZE), | |
| 132 | + })) | |
| 133 | + .filter((section) => section.items.length > 0); | |
| 134 | + }, [searched, category, searching]); | |
| 135 | + | |
| 136 | + return ( | |
| 137 | + <> | |
| 138 | + <div className="mb-4 flex gap-2"> | |
| 139 | + <div className="relative min-w-0 flex-1"> | |
| 140 | + <SearchIcon | |
| 141 | + className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" | |
| 142 | + aria-hidden | |
| 143 | + /> | |
| 144 | + <Input | |
| 145 | + value={query} | |
| 146 | + onChange={(event) => setQuery((event.target as HTMLInputElement).value)} | |
| 147 | + placeholder="Search integrations…" | |
| 148 | + aria-label="Search integrations" | |
| 149 | + className="h-11 pl-9 text-sm" | |
| 150 | + /> | |
| 151 | + </div> | |
| 152 | + <Button | |
| 153 | + type="button" | |
| 154 | + variant="outline" | |
| 155 | + className="h-11 shrink-0 gap-1.5" | |
| 156 | + onClick={props.onAddCustom} | |
| 157 | + > | |
| 158 | + <PlusIcon className="size-4" aria-hidden /> | |
| 159 | + Add custom | |
| 160 | + </Button> | |
| 161 | + </div> | |
| 162 | + | |
| 163 | + <div className="mb-8 flex flex-wrap gap-1.5"> | |
| 164 | + {["All", ...CATEGORY_ORDER].map((name) => ( | |
| 165 | + // oxlint-disable-next-line react/forbid-elements | |
| 166 | + <button | |
| 167 | + key={name} | |
| 168 | + type="button" | |
| 169 | + onClick={() => setCategory(name)} | |
| 170 | + className={cn( | |
| 171 | + "rounded-full border px-3 py-1 text-xs font-medium transition-colors", | |
| 172 | + category === name | |
| 173 | + ? "border-foreground/20 bg-foreground text-background" | |
| 174 | + : "border-border text-muted-foreground hover:bg-accent hover:text-foreground", | |
| 175 | + )} | |
| 176 | + > | |
| 177 | + {name} | |
| 178 | + </button> | |
| 179 | + ))} | |
| 180 | + </div> | |
| 181 | + | |
| 182 | + {items === null ? ( | |
| 183 | + <div className="flex flex-col gap-2"> | |
| 184 | + {Array.from({ length: 8 }).map((_, i) => ( | |
| 185 | + <div key={i} className="flex items-center gap-3 px-3 py-2.5"> | |
| 186 | + <Skeleton className="size-8 shrink-0 rounded-md" /> | |
| 187 | + <div className="flex min-w-0 flex-1 flex-col gap-1.5"> | |
| 188 | + <Skeleton className="h-3.5" style={{ width: `${20 + ((i * 11) % 20)}%` }} /> | |
| 189 | + <Skeleton className="h-3" style={{ width: `${45 + ((i * 13) % 30)}%` }} /> | |
| 190 | + </div> | |
| 191 | + <Skeleton className="h-8 w-14 rounded-md" /> | |
| 192 | + </div> | |
| 193 | + ))} | |
| 194 | + </div> | |
| 195 | + ) : searching ? ( | |
| 196 | + <div className="flex flex-col gap-1"> | |
| 197 | + {searched.slice(0, 40).map((item) => ( | |
| 198 | + <CatalogRow | |
| 199 | + key={item.domain} | |
| 200 | + item={item} | |
| 201 | + added={props.addedDomains.includes(item.domain)} | |
| 202 | + onAdd={() => props.onAdd(item)} | |
| 203 | + onOpen={() => props.onOpen(item)} | |
| 204 | + /> | |
| 205 | + ))} | |
| 206 | + <div className="mt-2"> | |
| 207 | + <CustomPrompt query={query} onAddCustom={props.onAddCustom} /> | |
| 208 | + </div> | |
| 209 | + </div> | |
| 210 | + ) : ( | |
| 211 | + <div className="flex flex-col gap-8"> | |
| 212 | + {sections.map((section) => ( | |
| 213 | + <section key={section.name}> | |
| 214 | + <h2 className="mb-2 px-3 text-xs font-medium uppercase tracking-widest text-muted-foreground"> | |
| 215 | + {section.name} | |
| 216 | + </h2> | |
| 217 | + <div className="flex flex-col gap-1"> | |
| 218 | + {section.items.map((item) => ( | |
| 219 | + <CatalogRow | |
| 220 | + key={item.domain} | |
| 221 | + item={item} | |
| 222 | + added={props.addedDomains.includes(item.domain)} | |
| 223 | + onAdd={() => props.onAdd(item)} | |
| 224 | + onOpen={() => props.onOpen(item)} | |
| 225 | + /> | |
| 226 | + ))} | |
| 227 | + </div> | |
| 228 | + </section> | |
| 229 | + ))} | |
| 230 | + </div> | |
| 231 | + )} | |
| 232 | + </> | |
| 233 | + ); | |
| 234 | +} | |
| @@ -0,0 +1,242 @@ | ||
| 1 | +// Reworked: adding something the registry has never heard of. | |
| 2 | +// | |
| 3 | +// This is the case the picker cannot cover and executor exists for — an | |
| 4 | +// internal API, a private MCP server, a spec behind a VPN. It has to be a | |
| 5 | +// first-class door, not the fallback you reach after search disappoints. | |
| 6 | +// | |
| 7 | +// The rule that keeps it simple: paste one URL, we say what we found, you add | |
| 8 | +// it. What comes out the other side is an ordinary added integration with an | |
| 9 | +// ordinary "Needs auth" account — identical to a registry pick from that point | |
| 10 | +// on, because the only thing the registry was ever providing is the definition. | |
| 11 | + | |
| 12 | +import { useState } from "react"; | |
| 13 | +import { ArrowUpRightIcon } from "lucide-react"; | |
| 14 | +import { Button } from "@executor-js/react/components/button"; | |
| 15 | +import { Label } from "@executor-js/react/components/label"; | |
| 16 | +import { | |
| 17 | + Dialog, | |
| 18 | + DialogContent, | |
| 19 | + DialogDescription, | |
| 20 | + DialogFooter, | |
| 21 | + DialogHeader, | |
| 22 | + DialogTitle, | |
| 23 | +} from "@executor-js/react/components/dialog"; | |
| 24 | +import { getDomain } from "tldts"; | |
| 25 | +import { logoFor, type AddedIntegration } from "../../catalog"; | |
| 26 | +import { INTEGRATIONS_SH_ORIGIN } from "../../fixtures"; | |
| 27 | + | |
| 28 | +type Kind = "mcp" | "openapi" | "graphql"; | |
| 29 | + | |
| 30 | +const KIND_LABEL: Record<Kind, string> = { | |
| 31 | + mcp: "MCP server", | |
| 32 | + openapi: "OpenAPI spec", | |
| 33 | + graphql: "GraphQL endpoint", | |
| 34 | +}; | |
| 35 | + | |
| 36 | +interface Detected { | |
| 37 | + readonly kind: Kind; | |
| 38 | + readonly domain: string; | |
| 39 | + readonly url: string; | |
| 40 | + /** A registry entry related to this URL, if there is one. `exact` separates | |
| 41 | + * two genuinely different situations that a string match cannot tell apart | |
| 42 | + * on its own: the host IS the registry entry, versus the host merely sits | |
| 43 | + * under a domain the registry knows. `mcp.posthog.com` under `posthog.com` | |
| 44 | + * is probably the same service; `internal.acme.com` under `acme.com` is | |
| 45 | + * probably not, so the copy must not claim it is. */ | |
| 46 | + readonly registryDomain: string | null; | |
| 47 | + readonly exact: boolean; | |
| 48 | +} | |
| 49 | + | |
| 50 | +const guessKind = (url: URL): Kind => { | |
| 51 | + const path = `${url.pathname}${url.search}`.toLowerCase(); | |
| 52 | + if (path.includes("graphql")) return "graphql"; | |
| 53 | + if (/\/mcp\b|\/sse\b/.test(path)) return "mcp"; | |
| 54 | + if (/openapi|swagger|\.ya?ml$|\.json$/.test(path)) return "openapi"; | |
| 55 | + // A bare origin is most often an MCP server these days; the detect step in | |
| 56 | + // the real product probes rather than guesses. | |
| 57 | + return path === "/" || path.length === 0 ? "mcp" : "openapi"; | |
| 58 | +}; | |
| 59 | + | |
| 60 | +const detect = async (raw: string): Promise<Detected | { readonly error: string }> => { | |
| 61 | + const trimmed = raw.trim(); | |
| 62 | + const withScheme = /^[a-z][a-z0-9+\-.]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; | |
| 63 | + const parsed = ((): URL | null => { | |
| 64 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL parsing reports failure by throwing | |
| 65 | + try { | |
| 66 | + return new URL(withScheme); | |
| 67 | + } catch { | |
| 68 | + return null; | |
| 69 | + } | |
| 70 | + })(); | |
| 71 | + if (!parsed) return { error: "That doesn't look like a URL." }; | |
| 72 | + | |
| 73 | + const host = parsed.hostname.replace(/^www\./, ""); | |
| 74 | + // The registry keys on the registrable domain, so an endpoint living on a | |
| 75 | + // subdomain (`mcp.posthog.com`) only matches once we also try its parent. | |
| 76 | + const candidates = [...new Set([host, getDomain(host)])].filter( | |
| 77 | + (candidate): candidate is string => candidate !== null && candidate.length > 0, | |
| 78 | + ); | |
| 79 | + const registryDomain = await (async () => { | |
| 80 | + for (const candidate of candidates) { | |
| 81 | + // A miss and a network failure mean the same thing here — nothing extra | |
| 82 | + // to offer — so both just move on to the next candidate. | |
| 83 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch reports transport failure by rejecting | |
| 84 | + try { | |
| 85 | + const response = await fetch( | |
| 86 | + `${INTEGRATIONS_SH_ORIGIN}/api/${encodeURIComponent(candidate)}/surface`, | |
| 87 | + ); | |
| 88 | + if (response.ok) return candidate; | |
| 89 | + } catch { | |
| 90 | + continue; | |
| 91 | + } | |
| 92 | + } | |
| 93 | + return null; | |
| 94 | + })(); | |
| 95 | + | |
| 96 | + return { | |
| 97 | + kind: guessKind(parsed), | |
| 98 | + domain: host, | |
| 99 | + url: parsed.toString(), | |
| 100 | + registryDomain, | |
| 101 | + exact: registryDomain === host, | |
| 102 | + }; | |
| 103 | +}; | |
| 104 | + | |
| 105 | +export function AddCustomDialog(props: { | |
| 106 | + readonly open: boolean; | |
| 107 | + readonly onOpenChange: (open: boolean) => void; | |
| 108 | + readonly onAdd: (integration: AddedIntegration) => void; | |
| 109 | +}) { | |
| 110 | + const [raw, setRaw] = useState(""); | |
| 111 | + const [busy, setBusy] = useState(false); | |
| 112 | + const [result, setResult] = useState<Detected | null>(null); | |
| 113 | + const [error, setError] = useState<string | null>(null); | |
| 114 | + | |
| 115 | + const run = async () => { | |
| 116 | + setBusy(true); | |
| 117 | + setError(null); | |
| 118 | + const outcome = await detect(raw); | |
| 119 | + setBusy(false); | |
| 120 | + if ("error" in outcome) { | |
| 121 | + setError(outcome.error); | |
| 122 | + setResult(null); | |
| 123 | + return; | |
| 124 | + } | |
| 125 | + setResult(outcome); | |
| 126 | + }; | |
| 127 | + | |
| 128 | + return ( | |
| 129 | + <Dialog open={props.open} onOpenChange={props.onOpenChange}> | |
| 130 | + <DialogContent className="sm:max-w-lg"> | |
| 131 | + <DialogHeader> | |
| 132 | + <DialogTitle>Add a custom integration</DialogTitle> | |
| 133 | + <DialogDescription> | |
| 134 | + Point at an MCP server, an OpenAPI spec, or a GraphQL endpoint. It does not need to be | |
| 135 | + in the registry. | |
| 136 | + </DialogDescription> | |
| 137 | + </DialogHeader> | |
| 138 | + | |
| 139 | + <div className="flex flex-col gap-4"> | |
| 140 | + <div className="space-y-2"> | |
| 141 | + <Label | |
| 142 | + htmlFor="custom-url" | |
| 143 | + className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground" | |
| 144 | + > | |
| 145 | + URL | |
| 146 | + </Label> | |
| 147 | + <div className="flex gap-2"> | |
| 148 | + {/* oxlint-disable-next-line react/forbid-elements */} | |
| 149 | + <input | |
| 150 | + id="custom-url" | |
| 151 | + // oxlint-disable-next-line jsx_a11y/no-autofocus -- the only field on the screen | |
| 152 | + autoFocus | |
| 153 | + value={raw} | |
| 154 | + onChange={(event) => { | |
| 155 | + setRaw(event.target.value); | |
| 156 | + setResult(null); | |
| 157 | + setError(null); | |
| 158 | + }} | |
| 159 | + onKeyDown={(event) => { | |
| 160 | + if (event.key === "Enter" && raw.trim().length > 0) void run(); | |
| 161 | + }} | |
| 162 | + placeholder="https://internal.acme.com/mcp" | |
| 163 | + className="h-10 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 font-mono text-sm outline-none transition-colors focus:border-ring dark:bg-input/30" | |
| 164 | + /> | |
| 165 | + <Button | |
| 166 | + type="button" | |
| 167 | + variant="outline" | |
| 168 | + disabled={raw.trim().length === 0 || busy} | |
| 169 | + loading={busy} | |
| 170 | + onClick={() => void run()} | |
| 171 | + > | |
| 172 | + Check | |
| 173 | + </Button> | |
| 174 | + </div> | |
| 175 | + {error ? <p className="text-xs text-destructive">{error}</p> : null} | |
| 176 | + </div> | |
| 177 | + | |
| 178 | + {result ? ( | |
| 179 | + <div className="space-y-2 rounded-lg border border-border bg-muted/20 px-3 py-3"> | |
| 180 | + <div className="flex items-center gap-2.5"> | |
| 181 | + <img | |
| 182 | + src={logoFor(result.domain)} | |
| 183 | + alt="" | |
| 184 | + className="size-6 shrink-0 rounded object-contain" | |
| 185 | + /> | |
| 186 | + <div className="min-w-0 flex-1"> | |
| 187 | + <p className="truncate text-sm font-medium text-foreground">{result.domain}</p> | |
| 188 | + <p className="text-xs text-muted-foreground">{KIND_LABEL[result.kind]}</p> | |
| 189 | + </div> | |
| 190 | + </div> | |
| 191 | + {result.registryDomain ? ( | |
| 192 | + <p className="text-xs leading-relaxed text-muted-foreground"> | |
| 193 | + {result.exact | |
| 194 | + ? `The registry already covers ${result.registryDomain}. Adding it from search brings the credential setup instructions with it — this URL will be added as-is, with none of that.` | |
| 195 | + : `The registry has an entry for ${result.registryDomain}, which may or may not be the same service as this host. This URL will be added as-is either way.`}{" "} | |
| 196 | + <a | |
| 197 | + href={`${INTEGRATIONS_SH_ORIGIN}/${result.registryDomain}/`} | |
| 198 | + target="_blank" | |
| 199 | + rel="noreferrer" | |
| 200 | + className="inline-flex items-center gap-0.5 font-medium text-foreground underline underline-offset-2" | |
| 201 | + > | |
| 202 | + See the entry | |
| 203 | + <ArrowUpRightIcon className="size-3" aria-hidden /> | |
| 204 | + </a> | |
| 205 | + </p> | |
| 206 | + ) : ( | |
| 207 | + <p className="text-xs leading-relaxed text-muted-foreground"> | |
| 208 | + Not in the registry. You'll set up its credential yourself on the next | |
| 209 | + screen. | |
| 210 | + </p> | |
| 211 | + )} | |
| 212 | + </div> | |
| 213 | + ) : null} | |
| 214 | + </div> | |
| 215 | + | |
| 216 | + <DialogFooter> | |
| 217 | + <Button type="button" variant="ghost" onClick={() => props.onOpenChange(false)}> | |
| 218 | + Cancel | |
| 219 | + </Button> | |
| 220 | + <Button | |
| 221 | + type="button" | |
| 222 | + disabled={result === null} | |
| 223 | + onClick={() => { | |
| 224 | + if (!result) return; | |
| 225 | + props.onAdd({ | |
| 226 | + domain: result.domain, | |
| 227 | + name: result.domain, | |
| 228 | + description: `${KIND_LABEL[result.kind]} at ${result.url}`, | |
| 229 | + icon: logoFor(result.domain), | |
| 230 | + formats: [result.kind], | |
| 231 | + source: "custom", | |
| 232 | + url: result.url, | |
| 233 | + }); | |
| 234 | + }} | |
| 235 | + > | |
| 236 | + Add integration | |
| 237 | + </Button> | |
| 238 | + </DialogFooter> | |
| 239 | + </DialogContent> | |
| 240 | + </Dialog> | |
| 241 | + ); | |
| 242 | +} | |
| @@ -0,0 +1,209 @@ | ||
| 1 | +// Reworked: one added integration. | |
| 2 | +// | |
| 3 | +// "Integration" and "connection" still exist — an integration can hold several | |
| 4 | +// accounts, which is a real capability — but the hierarchy is no longer | |
| 5 | +// something you have to understand before you can start. An add always leaves | |
| 6 | +// exactly one account row behind, already named, in the one state that matters: | |
| 7 | +// Needs auth, with the button that fixes it. Tools stay collapsed until there | |
| 8 | +// are some. | |
| 9 | +// | |
| 10 | +// A custom integration renders through this same screen. The only difference it | |
| 11 | +// is allowed to make is the one line naming where its definition came from. | |
| 12 | + | |
| 13 | +import { ArrowLeftIcon, ArrowUpRightIcon, ChevronDownIcon, PlusIcon } from "lucide-react"; | |
| 14 | +import { Button } from "@executor-js/react/components/button"; | |
| 15 | +import { cn } from "@executor-js/react/lib/utils"; | |
| 16 | +import type { AddedIntegration } from "../../catalog"; | |
| 17 | + | |
| 18 | +export interface DemoAccount { | |
| 19 | + readonly label: string; | |
| 20 | + readonly status: "needs-auth" | "connected"; | |
| 21 | + readonly identity?: string; | |
| 22 | +} | |
| 23 | + | |
| 24 | +function AccountRow(props: { readonly account: DemoAccount; readonly onAuthenticate: () => void }) { | |
| 25 | + const needsAuth = props.account.status === "needs-auth"; | |
| 26 | + return ( | |
| 27 | + <div className="flex items-center gap-3 px-3 py-2.5"> | |
| 28 | + <span | |
| 29 | + className={cn( | |
| 30 | + "size-2 shrink-0 rounded-full", | |
| 31 | + needsAuth ? "bg-amber-500" : "bg-emerald-500", | |
| 32 | + )} | |
| 33 | + aria-hidden | |
| 34 | + /> | |
| 35 | + <span className="min-w-0 flex-1"> | |
| 36 | + <span className="block truncate text-sm text-foreground"> | |
| 37 | + {props.account.identity ?? props.account.label} | |
| 38 | + </span> | |
| 39 | + </span> | |
| 40 | + {needsAuth ? ( | |
| 41 | + <> | |
| 42 | + <span className="shrink-0 text-xs font-medium text-amber-500">Needs auth</span> | |
| 43 | + <Button type="button" variant="outline" size="sm" onClick={props.onAuthenticate}> | |
| 44 | + Authenticate | |
| 45 | + </Button> | |
| 46 | + </> | |
| 47 | + ) : ( | |
| 48 | + <span className="shrink-0 text-xs text-muted-foreground">Connected</span> | |
| 49 | + )} | |
| 50 | + </div> | |
| 51 | + ); | |
| 52 | +} | |
| 53 | + | |
| 54 | +export function NextIntegrationDetail(props: { | |
| 55 | + readonly item: AddedIntegration; | |
| 56 | + readonly accounts: readonly DemoAccount[]; | |
| 57 | + readonly toolCount: number; | |
| 58 | + readonly onBack: () => void; | |
| 59 | + readonly onAuthenticate: (accountLabel: string) => void; | |
| 60 | + readonly onAddAccount: () => void; | |
| 61 | + readonly onRemove: () => void; | |
| 62 | +}) { | |
| 63 | + const custom = props.item.source === "custom"; | |
| 64 | + return ( | |
| 65 | + <> | |
| 66 | + <Button | |
| 67 | + type="button" | |
| 68 | + variant="ghost" | |
| 69 | + size="sm" | |
| 70 | + className="-ml-2 mb-6 gap-1.5 text-muted-foreground" | |
| 71 | + onClick={props.onBack} | |
| 72 | + > | |
| 73 | + <ArrowLeftIcon className="size-4" /> | |
| 74 | + All integrations | |
| 75 | + </Button> | |
| 76 | + | |
| 77 | + <div className="mb-6 flex items-start gap-4"> | |
| 78 | + <img src={props.item.icon} alt="" className="size-12 shrink-0 rounded-xl object-contain" /> | |
| 79 | + <div className="min-w-0 flex-1"> | |
| 80 | + <h1 className="text-lg font-semibold text-foreground">{props.item.name}</h1> | |
| 81 | + {custom ? ( | |
| 82 | + <p className="mt-0.5 truncate font-mono text-xs text-muted-foreground"> | |
| 83 | + {props.item.url} | |
| 84 | + </p> | |
| 85 | + ) : ( | |
| 86 | + <a | |
| 87 | + href={`https://integrations.sh/${props.item.domain}/`} | |
| 88 | + target="_blank" | |
| 89 | + rel="noreferrer" | |
| 90 | + className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground" | |
| 91 | + > | |
| 92 | + {props.item.domain} | |
| 93 | + <ArrowUpRightIcon className="size-3" aria-hidden /> | |
| 94 | + </a> | |
| 95 | + )} | |
| 96 | + </div> | |
| 97 | + <Button type="button" variant="outline" size="sm" onClick={props.onRemove}> | |
| 98 | + Remove | |
| 99 | + </Button> | |
| 100 | + </div> | |
| 101 | + | |
| 102 | + <p className="mb-8 text-sm leading-relaxed text-muted-foreground">{props.item.description}</p> | |
| 103 | + | |
| 104 | + <section className="mb-6"> | |
| 105 | + <h2 className="mb-2 text-xs font-medium uppercase tracking-widest text-muted-foreground"> | |
| 106 | + Accounts | |
| 107 | + </h2> | |
| 108 | + <div className="overflow-hidden rounded-lg border border-border"> | |
| 109 | + <div className="divide-y divide-border"> | |
| 110 | + {props.accounts.map((account) => ( | |
| 111 | + <AccountRow | |
| 112 | + key={account.label} | |
| 113 | + account={account} | |
| 114 | + onAuthenticate={() => props.onAuthenticate(account.label)} | |
| 115 | + /> | |
| 116 | + ))} | |
| 117 | + </div> | |
| 118 | + {/* oxlint-disable-next-line react/forbid-elements */} | |
| 119 | + <button | |
| 120 | + type="button" | |
| 121 | + onClick={props.onAddAccount} | |
| 122 | + className="flex w-full items-center gap-1.5 border-t border-border px-3 py-2.5 text-left text-xs font-medium text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground" | |
| 123 | + > | |
| 124 | + <PlusIcon className="size-3.5" aria-hidden /> | |
| 125 | + Add another account | |
| 126 | + </button> | |
| 127 | + </div> | |
| 128 | + </section> | |
| 129 | + | |
| 130 | + <section> | |
| 131 | + <div className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5"> | |
| 132 | + <span className="text-sm text-foreground"> | |
| 133 | + {props.toolCount === 0 | |
| 134 | + ? "Tools appear once an account is connected" | |
| 135 | + : `${props.toolCount} tools`} | |
| 136 | + </span> | |
| 137 | + <ChevronDownIcon className="size-4 text-muted-foreground" aria-hidden /> | |
| 138 | + </div> | |
| 139 | + </section> | |
| 140 | + </> | |
| 141 | + ); | |
| 142 | +} | |
| 143 | + | |
| 144 | +/** The installed list. Without a sidebar this is how you get back to something | |
| 145 | + * you added, so it carries the same status vocabulary as the detail screen. */ | |
| 146 | +export function InstalledList(props: { | |
| 147 | + readonly items: readonly { | |
| 148 | + readonly item: AddedIntegration; | |
| 149 | + readonly accounts: readonly DemoAccount[]; | |
| 150 | + }[]; | |
| 151 | + readonly onOpen: (domain: string) => void; | |
| 152 | + readonly onBrowse: () => void; | |
| 153 | +}) { | |
| 154 | + if (props.items.length === 0) { | |
| 155 | + return ( | |
| 156 | + <div className="rounded-lg border border-dashed border-border py-16 text-center"> | |
| 157 | + <p className="text-sm font-medium text-foreground">Nothing added yet</p> | |
| 158 | + <p className="mt-1 text-sm text-muted-foreground"> | |
| 159 | + Add an integration and it shows up here. | |
| 160 | + </p> | |
| 161 | + <Button type="button" size="sm" className="mt-4" onClick={props.onBrowse}> | |
| 162 | + Browse integrations | |
| 163 | + </Button> | |
| 164 | + </div> | |
| 165 | + ); | |
| 166 | + } | |
| 167 | + | |
| 168 | + return ( | |
| 169 | + <div className="flex flex-col gap-1"> | |
| 170 | + {props.items.map(({ item, accounts }) => { | |
| 171 | + const pending = accounts.filter((account) => account.status === "needs-auth").length; | |
| 172 | + return ( | |
| 173 | + // oxlint-disable-next-line react/forbid-elements | |
| 174 | + <button | |
| 175 | + key={item.domain} | |
| 176 | + type="button" | |
| 177 | + onClick={() => props.onOpen(item.domain)} | |
| 178 | + className="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors hover:bg-accent/40" | |
| 179 | + > | |
| 180 | + <img | |
| 181 | + src={item.icon} | |
| 182 | + alt="" | |
| 183 | + loading="lazy" | |
| 184 | + className="size-8 shrink-0 rounded-md object-contain" | |
| 185 | + /> | |
| 186 | + <span className="min-w-0 flex-1"> | |
| 187 | + <span className="flex items-center gap-2"> | |
| 188 | + <span className="truncate text-sm font-medium text-foreground">{item.name}</span> | |
| 189 | + {item.source === "custom" ? ( | |
| 190 | + <span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-muted-foreground"> | |
| 191 | + Custom | |
| 192 | + </span> | |
| 193 | + ) : null} | |
| 194 | + </span> | |
| 195 | + <span className="block truncate text-xs text-muted-foreground"> | |
| 196 | + {accounts.length} account{accounts.length === 1 ? "" : "s"} | |
| 197 | + </span> | |
| 198 | + </span> | |
| 199 | + {pending > 0 ? ( | |
| 200 | + <span className="shrink-0 text-xs font-medium text-amber-500">Needs auth</span> | |
| 201 | + ) : ( | |
| 202 | + <span className="shrink-0 text-xs text-muted-foreground">Connected</span> | |
| 203 | + )} | |
| 204 | + </button> | |
| 205 | + ); | |
| 206 | + })} | |
| 207 | + </div> | |
| 208 | + ); | |
| 209 | +} | |
| @@ -0,0 +1,97 @@ | ||
| 1 | +// Reproduction of the console chrome (packages/app/src/web/shell.tsx): the | |
| 2 | +// wordmark bar, the five nav items, the second INTEGRATIONS heading that | |
| 3 | +// repeats the first nav item, and the four-link footer. | |
| 4 | + | |
| 5 | +import type { ReactNode } from "react"; | |
| 6 | +import { cn } from "@executor-js/react/lib/utils"; | |
| 7 | +import type { DemoIntegration } from "./fixtures"; | |
| 8 | + | |
| 9 | +const NAV_ITEMS = ["Integrations", "Secrets", "Policies", "Toolkits", "Artifacts"] as const; | |
| 10 | + | |
| 11 | +function NavItem(props: { readonly label: string; readonly active: boolean }) { | |
| 12 | + return ( | |
| 13 | + <span | |
| 14 | + className={cn( | |
| 15 | + "flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors", | |
| 16 | + props.active | |
| 17 | + ? "bg-sidebar-active font-medium text-foreground" | |
| 18 | + : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", | |
| 19 | + )} | |
| 20 | + > | |
| 21 | + {props.label} | |
| 22 | + </span> | |
| 23 | + ); | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function Shell(props: { | |
| 27 | + readonly integrations: readonly DemoIntegration[]; | |
| 28 | + readonly children: ReactNode; | |
| 29 | +}) { | |
| 30 | + return ( | |
| 31 | + <div className="flex h-full min-h-0 w-full"> | |
| 32 | + <aside className="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar"> | |
| 33 | + <div className="flex h-12 shrink-0 items-center gap-2 border-b border-sidebar-border px-4"> | |
| 34 | + <span className="font-mono text-sm font-medium tracking-tight text-foreground"> | |
| 35 | + executor | |
| 36 | + </span> | |
| 37 | + <span className="ml-auto truncate font-mono text-[11px] text-muted-foreground"> | |
| 38 | + theo's org | |
| 39 | + </span> | |
| 40 | + </div> | |
| 41 | + | |
| 42 | + <nav className="flex flex-1 flex-col overflow-y-auto p-2"> | |
| 43 | + {NAV_ITEMS.map((label) => ( | |
| 44 | + <NavItem key={label} label={label} active={label === "Integrations"} /> | |
| 45 | + ))} | |
| 46 | + | |
| 47 | + <span className="mt-5 mb-1 px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground"> | |
| 48 | + Integrations | |
| 49 | + </span> | |
| 50 | + | |
| 51 | + {props.integrations.length === 0 ? ( | |
| 52 | + <div className="px-2.5 py-2 text-sm leading-relaxed text-muted-foreground"> | |
| 53 | + No integrations yet | |
| 54 | + </div> | |
| 55 | + ) : ( | |
| 56 | + <div className="flex flex-col gap-px"> | |
| 57 | + {props.integrations.map((integration) => ( | |
| 58 | + <span | |
| 59 | + key={integration.slug} | |
| 60 | + className="group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-active/60 hover:text-foreground" | |
| 61 | + > | |
| 62 | + {integration.icon ? ( | |
| 63 | + <img | |
| 64 | + src={integration.icon} | |
| 65 | + alt="" | |
| 66 | + className="size-4 rounded-sm object-contain" | |
| 67 | + /> | |
| 68 | + ) : ( | |
| 69 | + <span className="size-4 rounded-sm bg-muted" /> | |
| 70 | + )} | |
| 71 | + <span className="flex-1 truncate">{integration.name}</span> | |
| 72 | + </span> | |
| 73 | + ))} | |
| 74 | + </div> | |
| 75 | + )} | |
| 76 | + </nav> | |
| 77 | + | |
| 78 | + <div className="shrink-0 border-t border-sidebar-border px-4 py-2.5"> | |
| 79 | + <div className="flex flex-col gap-1.5 text-xs leading-none text-muted-foreground"> | |
| 80 | + <span className="flex items-center justify-between"> | |
| 81 | + <span>Commands</span> | |
| 82 | + <span className="font-mono text-[11px]">⌘K</span> | |
| 83 | + </span> | |
| 84 | + <span>Docs</span> | |
| 85 | + <span>Feedback / bug?</span> | |
| 86 | + <span>Star on GitHub</span> | |
| 87 | + <span className="mt-0.5 tabular-nums">v1.4.4</span> | |
| 88 | + </div> | |
| 89 | + </div> | |
| 90 | + </aside> | |
| 91 | + | |
| 92 | + <main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"> | |
| 93 | + {props.children} | |
| 94 | + </main> | |
| 95 | + </div> | |
| 96 | + ); | |
| 97 | +} | |
| @@ -0,0 +1,45 @@ | ||
| 1 | +// Which screens carry which workspace state. Jumping straight to a late step | |
| 2 | +// has to bring that step's state with it, or the screen is not the one the | |
| 3 | +// step is about. | |
| 4 | + | |
| 5 | +import type { StepId } from "./flow"; | |
| 6 | +import { gmailIntegration, posthogIntegration, type DemoIntegration } from "./fixtures"; | |
| 7 | + | |
| 8 | +export type AccountModal = "none" | "credential" | "place" | "oauth-stuck"; | |
| 9 | +export type ScreenView = "integrations" | "add" | "detail"; | |
| 10 | + | |
| 11 | +export interface StepState { | |
| 12 | + readonly integrations: readonly DemoIntegration[]; | |
| 13 | + readonly connectOpen: boolean; | |
| 14 | + readonly accountModal: AccountModal; | |
| 15 | + readonly view: ScreenView; | |
| 16 | +} | |
| 17 | + | |
| 18 | +const EMPTY: StepState = { | |
| 19 | + integrations: [], | |
| 20 | + connectOpen: false, | |
| 21 | + accountModal: "none", | |
| 22 | + view: "integrations", | |
| 23 | +}; | |
| 24 | + | |
| 25 | +const WITH_POSTHOG: StepState = { | |
| 26 | + integrations: [posthogIntegration], | |
| 27 | + connectOpen: false, | |
| 28 | + accountModal: "none", | |
| 29 | + view: "detail", | |
| 30 | +}; | |
| 31 | + | |
| 32 | +export const stepState: Readonly<Record<StepId, StepState>> = { | |
| 33 | + "integrations-empty": EMPTY, | |
| 34 | + "connect-dialog": { ...EMPTY, connectOpen: true }, | |
| 35 | + "add-openapi": { ...EMPTY, view: "add" }, | |
| 36 | + "detail-accounts": WITH_POSTHOG, | |
| 37 | + "add-account-credential": { ...WITH_POSTHOG, accountModal: "credential" }, | |
| 38 | + "add-account-place": { ...WITH_POSTHOG, accountModal: "place" }, | |
| 39 | + "oauth-stuck": { | |
| 40 | + ...WITH_POSTHOG, | |
| 41 | + integrations: [gmailIntegration], | |
| 42 | + accountModal: "oauth-stuck", | |
| 43 | + }, | |
| 44 | + "integrations-populated": { ...WITH_POSTHOG, view: "integrations" }, | |
| 45 | +}; | |
| @@ -0,0 +1,5 @@ | ||
| 1 | +/* The console's real stylesheet — same tokens, same primitives. The extra | |
| 2 | + @source is this package's own sources, which the react package's globs | |
| 3 | + (rooted at packages/react/src) do not cover. */ | |
| 4 | +@import "@executor-js/react/globals.css"; | |
| 5 | +@source "./**/*.{ts,tsx}"; | |
| @@ -0,0 +1,15 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ESNext", | |
| 4 | + "module": "ESNext", | |
| 5 | + "moduleResolution": "bundler", | |
| 6 | + "allowImportingTsExtensions": true, | |
| 7 | + "strict": true, | |
| 8 | + "esModuleInterop": true, | |
| 9 | + "skipLibCheck": true, | |
| 10 | + "noEmit": true, | |
| 11 | + "jsx": "react-jsx", | |
| 12 | + "types": ["vite/client"] | |
| 13 | + }, | |
| 14 | + "include": ["src", "vite.config.ts"] | |
| 15 | +} | |
| @@ -0,0 +1,12 @@ | ||
| 1 | +import { defineConfig } from "vite"; | |
| 2 | +import react from "@vitejs/plugin-react"; | |
| 3 | +import tailwindcss from "@tailwindcss/vite"; | |
| 4 | + | |
| 5 | +// Standalone SPA reproduction of the current add-integration flow. No executor | |
| 6 | +// API, no auth, no backend — every screen runs off `src/fixtures.ts` plus the | |
| 7 | +// live public integrations.sh catalog, so the flow can be redesigned without | |
| 8 | +// standing up a tenant. | |
| 9 | +export default defineConfig({ | |
| 10 | + plugins: [react(), tailwindcss()], | |
| 11 | + server: { port: 5199 }, | |
| 12 | +}); | |
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | ||
| 15 | 15 | type AuthMethodRow, |
| 16 | 16 | type AuthMethodSeed, |
| 17 | 17 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 18 | +import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | |
| 18 | 19 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 19 | 20 | import { |
| 20 | 21 | addIntegrationErrorMessage, |
| @@ -42,13 +43,25 @@ export default function AddGraphqlIntegration(props: { · 21 unmodified lines | ||
| 42 | 43 | onComplete: (slug?: string) => void; |
| 43 | 44 | onCancel: () => void; |
| 44 | 45 | initialUrl?: string; |
| 46 | + initialAuthHeader?: string; | |
| 47 | + initialAuthNote?: string; | |
| 45 | 48 | }) { |
| 46 | 49 | const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); |
| 47 | 50 | const [description, setDescription] = useState(""); |
| 48 | 51 | const identity = useIntegrationIdentity({ |
| 49 | 52 | fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "", |
| 50 | 53 | }); |
| 51 | − const authMethodList = useAuthMethodList(NO_SEEDS); | |
| 54 | + // GraphQL has no add-time detection, but the registry can declare the | |
| 55 | + // credential placement ("Authorization: {api_key}" — Linear's no-Bearer | |
| 56 | + // personal keys). A declared placement seeds the method list the same way a | |
| 57 | + // spec's security scheme would. | |
| 58 | + const registrySeeds = useMemo<readonly AuthMethodSeed[]>(() => { | |
| 59 | + const placement = props.initialAuthHeader | |
| 60 | + ? placementFromHeaderPattern(props.initialAuthHeader) | |
| 61 | + : null; | |
| 62 | + return placement ? [{ value: { kind: "apikey", placements: [placement] } }] : NO_SEEDS; | |
| 63 | + }, [props.initialAuthHeader]); | |
| 64 | + const authMethodList = useAuthMethodList(registrySeeds); | |
| 52 | 65 | const [adding, setAdding] = useState(false); |
| 53 | 66 | const [addError, setAddError] = useState<string | null>(null); |
| 54 | 67 | |
| @@ -144,9 +157,13 @@ export default function AddGraphqlIntegration(props: { · 89 unmodified lines | ||
| 144 | 157 | list={authMethodList} |
| 145 | 158 | allowedKinds={["none", "apikey"]} |
| 146 | 159 | emptyHint="No authentication declared. Add a method, or add the integration without auth and connect an account from the integration page later." |
| 147 | − footerHint="Every method here is registered with the integration. Connect an account from the integration page after adding." | |
| 160 | + footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page." | |
| 148 | 161 | /> |
| 149 | 162 | |
| 163 | + {props.initialAuthNote ? ( | |
| 164 | + <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p> | |
| 165 | + ) : null} | |
| 166 | + | |
| 150 | 167 | {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />} |
| 151 | 168 | |
| 152 | 169 | {addError && <FormErrorAlert message={addError} />} |
| @@ -1,6 +1,49 @@ | ||
| 1 | −import { lazy } from "react"; | |
| 2 | −import type { IntegrationPlugin } from "@executor-js/sdk/client"; | |
| 1 | +import { lazy, useCallback } from "react"; | |
| 2 | +import { useAtomSet } from "@effect/atom-react"; | |
| 3 | +import * as Exit from "effect/Exit"; | |
| 4 | +import type { | |
| 5 | + IntegrationPlugin, | |
| 6 | + IntegrationQuickAddInput, | |
| 7 | + IntegrationQuickAddResult, | |
| 8 | +} from "@executor-js/sdk/client"; | |
| 9 | +import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | |
| 10 | +import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; | |
| 11 | +import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | |
| 3 | 12 | import { graphqlPresets } from "../sdk/presets"; |
| 13 | +import { createGraphqlIntegrationOptimistic } from "./atoms"; | |
| 14 | +import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config"; | |
| 15 | + | |
| 16 | +/** One-click add for a registry GraphQL row. A GraphQL endpoint cannot | |
| 17 | + * describe its own auth, so the registry's declared header pattern is the | |
| 18 | + * only carrier of facts like Linear's no-Bearer key — it becomes the | |
| 19 | + * declared method, exactly as the add page would seed it. */ | |
| 20 | +function useGraphqlQuickAdd(): ( | |
| 21 | + input: IntegrationQuickAddInput, | |
| 22 | +) => Promise<IntegrationQuickAddResult> { | |
| 23 | + const doAdd = useAtomSet(createGraphqlIntegrationOptimistic, { mode: "promiseExit" }); | |
| 24 | + return useCallback( | |
| 25 | + async (input) => { | |
| 26 | + const slug = slugifyNamespace(input.slug ?? input.name); | |
| 27 | + if (!slug) return { ok: false, reason: "no derivable slug" }; | |
| 28 | + const placement = input.authHeader ? placementFromHeaderPattern(input.authHeader) : null; | |
| 29 | + const authenticationTemplate = placement | |
| 30 | + ? graphqlAuthMethodInputsFromPlacements([placement]) | |
| 31 | + : []; | |
| 32 | + const exit = await doAdd({ | |
| 33 | + payload: { | |
| 34 | + endpoint: input.url, | |
| 35 | + slug, | |
| 36 | + name: input.name, | |
| 37 | + ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}), | |
| 38 | + }, | |
| 39 | + reactivityKeys: integrationWriteKeys, | |
| 40 | + }); | |
| 41 | + if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" }; | |
| 42 | + return { ok: true, slug: String(exit.value.slug) }; | |
| 43 | + }, | |
| 44 | + [doAdd], | |
| 45 | + ); | |
| 46 | +} | |
| 4 | 47 | |
| 5 | 48 | const importAdd = () => import("./AddGraphqlIntegration"); |
| 6 | 49 | const importAccounts = () => import("./GraphqlAccountsPanel"); |
| @@ -12,9 +55,10 @@ export const graphqlIntegrationPlugin: IntegrationPlugin = { · 5 unmodified lines | ||
| 12 | 55 | label: "GraphQL", |
| 13 | 56 | add: lazy(importAdd), |
| 14 | 57 | accounts: lazy(importAccounts), |
| 15 | − presets: graphqlPresets, | |
| 58 | + presets: graphqlPresets.map((preset) => ({ ...preset, registryListed: true })), | |
| 16 | 59 | preload: () => { |
| 17 | 60 | void importAdd(); |
| 18 | 61 | void importAccounts(); |
| 19 | 62 | }, |
| 63 | + useQuickAdd: useGraphqlQuickAdd, | |
| 20 | 64 | }; |
| @@ -36,6 +36,11 @@ const AddRemoteServerPayload = Schema.Struct({ · 35 unmodified lines | ||
| 36 | 36 | description: Schema.optional(Schema.String), |
| 37 | 37 | endpoint: Schema.String, |
| 38 | 38 | remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), |
| 39 | + /** Pin legacy protocol negotiation for a server that echoes the modern | |
| 40 | + * revision but breaks its contract (the probe reports when only legacy | |
| 41 | + * worked). Omitting this here silently stripped the pin from the stored | |
| 42 | + * integration, leaving it unusable against exactly that server. */ | |
| 43 | + versionNegotiation: Schema.optional(Schema.Literals(["auto", "legacy"])), | |
| 39 | 44 | slug: Schema.optional(Schema.String), |
| 40 | 45 | queryParams: Schema.optional(StringMap), |
| 41 | 46 | headers: Schema.optional(StringMap), |
| @@ -100,6 +105,12 @@ const ProbeEndpointResponse = Schema.Struct({ · 58 unmodified lines | ||
| 100 | 105 | serverName: Schema.NullOr(Schema.String), |
| 101 | 106 | /** Server `instructions` from initialize — prefills the description field. */ |
| 102 | 107 | instructions: Schema.NullOr(Schema.String), |
| 108 | + /** Which protocol negotiation worked, when discovery succeeded. `legacy` | |
| 109 | + * means the server echoes the modern revision but breaks its contract, and | |
| 110 | + * the add must pin `versionNegotiation: "legacy"` on the integration — | |
| 111 | + * omitting this field here silently stripped it from the HTTP response and | |
| 112 | + * the pin never happened. */ | |
| 113 | + versionNegotiation: Schema.optional(Schema.Literals(["auto", "legacy"])), | |
| 103 | 114 | }); |
| 104 | 115 | |
| 105 | 116 | // --------------------------------------------------------------------------- |
| @@ -120,4 +120,37 @@ describe("McpHandlers", () => { · 119 unmodified lines | ||
| 120 | 120 | expect(body.message).toContain("Do you need to provide an API key"); |
| 121 | 121 | }), |
| 122 | 122 | ); |
| 123 | + | |
| 124 | + it.effect("the remote add carries versionNegotiation through to the extension", () => | |
| 125 | + Effect.gen(function* () { | |
| 126 | + // The probe's legacy retry is pointless if the pin dies at the HTTP | |
| 127 | + // boundary: this exact field was silently stripped by the payload | |
| 128 | + // schema and the handler's explicit field map. | |
| 129 | + let received: unknown; | |
| 130 | + const web = yield* webHandlerFor({ | |
| 131 | + ...failingExtension, | |
| 132 | + addServer: (input) => { | |
| 133 | + received = input; | |
| 134 | + return Effect.succeed({ slug: "pinned", tools: [] } as never); | |
| 135 | + }, | |
| 136 | + }); | |
| 137 | + const response = yield* Effect.promise(() => | |
| 138 | + (web.handler as (request: Request) => Promise<Response>)( | |
| 139 | + new Request("http://localhost/mcp/servers", { | |
| 140 | + method: "POST", | |
| 141 | + headers: { "content-type": "application/json" }, | |
| 142 | + body: JSON.stringify({ | |
| 143 | + transport: "remote", | |
| 144 | + name: "Echoing server", | |
| 145 | + endpoint: "https://example.com/mcp", | |
| 146 | + versionNegotiation: "legacy", | |
| 147 | + authenticationTemplate: [{ kind: "none" }], | |
| 148 | + }), | |
| 149 | + }), | |
| 150 | + ), | |
| 151 | + ); | |
| 152 | + expect(response.status).toBeLessThan(500); | |
| 153 | + expect((received as { versionNegotiation?: string }).versionNegotiation).toBe("legacy"); | |
| 154 | + }), | |
| 155 | + ); | |
| 123 | 156 | }); |
| @@ -71,6 +71,7 @@ const toServerInput = ( · 70 unmodified lines | ||
| 71 | 71 | description?: string; |
| 72 | 72 | endpoint: string; |
| 73 | 73 | remoteTransport?: "streamable-http" | "sse" | "auto"; |
| 74 | + versionNegotiation?: "auto" | "legacy"; | |
| 74 | 75 | queryParams?: Record<string, string>; |
| 75 | 76 | headers?: Record<string, string>; |
| 76 | 77 | slug?: string; |
| @@ -87,6 +88,7 @@ const toServerInput = ( · 10 unmodified lines | ||
| 87 | 88 | description: p.description, |
| 88 | 89 | endpoint: p.endpoint, |
| 89 | 90 | remoteTransport: p.remoteTransport, |
| 91 | + versionNegotiation: p.versionNegotiation, | |
| 90 | 92 | queryParams: p.queryParams, |
| 91 | 93 | headers: p.headers, |
| 92 | 94 | slug: p.slug, |
| @@ -37,10 +37,15 @@ import { · 36 unmodified lines | ||
| 37 | 37 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 38 | 38 | import type { McpAuthMethodInput } from "../sdk/types"; |
| 39 | 39 | import { probeMcpEndpoint, addMcpServer } from "./atoms"; |
| 40 | +import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | |
| 40 | 41 | import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; |
| 41 | 42 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; |
| 42 | 43 | import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; |
| 43 | −import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; | |
| 44 | +import { | |
| 45 | + mcpAuthMethodInputFromEditorValue, | |
| 46 | + mcpDetectedAuthSeeds, | |
| 47 | + mcpWireAuthInput, | |
| 48 | +} from "./auth-method-config"; | |
| 44 | 49 | import CodexPluginAdd from "./CodexPluginAdd"; |
| 45 | 50 | import { parseStdioArgs } from "./stdio-fields"; |
| 46 | 51 | import { isProbableMcpEndpoint } from "./probe-url"; |
| @@ -79,6 +84,8 @@ type ProbeResult = { · 32 unmodified lines | ||
| 79 | 84 | toolCount: number | null; |
| 80 | 85 | serverName: string | null; |
| 81 | 86 | instructions: string | null; |
| 87 | + /** "legacy" when only the legacy handshake worked (version-echoing server). */ | |
| 88 | + versionNegotiation?: "auto" | "legacy"; | |
| 82 | 89 | }; |
| 83 | 90 | |
| 84 | 91 | type State = |
| @@ -161,6 +168,9 @@ export default function AddMcpIntegration(props: { · 76 unmodified lines | ||
| 161 | 168 | onCancel: () => void; |
| 162 | 169 | initialUrl?: string; |
| 163 | 170 | initialPreset?: string; |
| 171 | + initialAuthHeader?: string; | |
| 172 | + initialAuthNote?: string; | |
| 173 | + initialAuthKind?: string; | |
| 164 | 174 | /** Whether the stdio transport is enabled on the server. */ |
| 165 | 175 | allowStdio?: boolean; |
| 166 | 176 | }) { |
| @@ -219,29 +229,16 @@ export default function AddMcpIntegration(props: { · 52 unmodified lines | ||
| 219 | 229 | // The probe seeds the method list: detected OAuth → an OAuth row; a 401 |
| 220 | 230 | // without OAuth metadata → a bearer-header row; an open server → a no-auth |
| 221 | 231 | // row. The user can edit any row or add alternate methods alongside. |
| 222 | − const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => { | |
| 223 | − if (!probe) return []; | |
| 224 | − if (probe.requiresOAuth) { | |
| 225 | − return [ | |
| 226 | − { | |
| 227 | − value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }, | |
| 228 | − label: "Detected", | |
| 229 | − }, | |
| 230 | − ]; | |
| 231 | − } | |
| 232 | − if (probe.requiresAuthentication) { | |
| 233 | − return [ | |
| 234 | − { | |
| 235 | − value: { | |
| 236 | − kind: "apikey", | |
| 237 | − placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | |
| 238 | − }, | |
| 239 | − label: "Detected", | |
| 240 | − }, | |
| 241 | − ]; | |
| 242 | − } | |
| 243 | − return [{ value: { kind: "none" }, label: "Detected" }]; | |
| 244 | − }, [probe]); | |
| 232 | + const authMethodSeeds: readonly AuthMethodSeed[] = useMemo( | |
| 233 | + () => | |
| 234 | + mcpDetectedAuthSeeds(probe, { | |
| 235 | + placement: props.initialAuthHeader | |
| 236 | + ? placementFromHeaderPattern(props.initialAuthHeader) | |
| 237 | + : null, | |
| 238 | + kind: props.initialAuthKind, | |
| 239 | + }), | |
| 240 | + [probe, props.initialAuthHeader, props.initialAuthKind], | |
| 241 | + ); | |
| 245 | 242 | const authMethodList = useAuthMethodList(authMethodSeeds); |
| 246 | 243 | |
| 247 | 244 | const remoteIdentity = useIntegrationIdentity({ |
| @@ -337,6 +334,12 @@ export default function AddMcpIntegration(props: { · 89 unmodified lines | ||
| 337 | 334 | ...(slug ? { slug } : {}), |
| 338 | 335 | ...(headers ? { headers } : {}), |
| 339 | 336 | authenticationTemplate, |
| 337 | + // The probe reports when only legacy negotiation worked (a server | |
| 338 | + // that echoes the modern revision but breaks its contract); pin it | |
| 339 | + // so refreshes and tool calls use the same handshake. | |
| 340 | + ...(probe?.versionNegotiation === "legacy" | |
| 341 | + ? { versionNegotiation: "legacy" as const } | |
| 342 | + : {}), | |
| 340 | 343 | }, |
| 341 | 344 | reactivityKeys: integrationWriteKeys, |
| 342 | 345 | }); |
| @@ -496,10 +499,14 @@ export default function AddMcpIntegration(props: { · 153 unmodified lines | ||
| 496 | 499 | title="How does this server authenticate?" |
| 497 | 500 | oauthMetadata="discovered" |
| 498 | 501 | emptyHint="No methods declared. Add a method, or add the server without auth and connect from the integration page later." |
| 499 | − footerHint="Every method here is registered with the server. Connect an account from the integration page after adding." | |
| 502 | + footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page." | |
| 500 | 503 | /> |
| 501 | 504 | )} |
| 502 | 505 | |
| 506 | + {probe && props.initialAuthNote ? ( | |
| 507 | + <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p> | |
| 508 | + ) : null} | |
| 509 | + | |
| 503 | 510 | {/* Error (add server). Probe errors show inline on the field. */} |
| 504 | 511 | {otherError && ( |
| 505 | 512 | <div className="space-y-2"> |
| @@ -9,6 +9,7 @@ · 8 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 11 | 11 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; |
| 12 | +import type { AuthMethodSeed } from "@executor-js/react/components/auth-method-list-editor"; | |
| 12 | 13 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; |
| 13 | 14 | import { |
| 14 | 15 | authMethodFromSharedTemplate, |
| @@ -126,3 +127,55 @@ export function mcpAuthMethodInputsFromPlacements( · 111 unmodified lines | ||
| 126 | 127 | if (wire.length === 0) return []; |
| 127 | 128 | return [{ kind: "apikey", placements: wire }]; |
| 128 | 129 | } |
| 130 | + | |
| 131 | +/** The auth methods a registry-driven MCP add declares before any editing: | |
| 132 | + * the probe's detection plus the registry's declared facts. ONE policy for | |
| 133 | + * both the add page (as editor seeds) and the one-click quick add (mapped | |
| 134 | + * straight to wire inputs) — two copies drifted is how the picker and the | |
| 135 | + * add page end up declaring different methods for the same server. */ | |
| 136 | +export function mcpDetectedAuthSeeds( | |
| 137 | + probe: { | |
| 138 | + readonly requiresOAuth: boolean; | |
| 139 | + readonly requiresAuthentication: boolean; | |
| 140 | + } | null, | |
| 141 | + registry: { | |
| 142 | + readonly placement?: Placement | null; | |
| 143 | + readonly kind?: string | undefined; | |
| 144 | + }, | |
| 145 | +): readonly AuthMethodSeed[] { | |
| 146 | + const registryPlacement = registry.placement ?? null; | |
| 147 | + if (!probe) { | |
| 148 | + // No probe result (pending, or the server was unreachable from here). | |
| 149 | + // The registry's declared facts still stand: an authless server or a | |
| 150 | + // known header pattern seeds the list the probe would have produced. | |
| 151 | + if (registryPlacement) return [{ value: { kind: "apikey", placements: [registryPlacement] } }]; | |
| 152 | + if (registry.kind === "none") return [{ value: { kind: "none" } }]; | |
| 153 | + return []; | |
| 154 | + } | |
| 155 | + if (probe.requiresOAuth) { | |
| 156 | + const oauth: AuthMethodSeed = { | |
| 157 | + value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }, | |
| 158 | + label: "Detected", | |
| 159 | + }; | |
| 160 | + // GitHub's MCP server takes a PAT bearer header in clients without | |
| 161 | + // OAuth; when the registry declared that placement, offer it alongside. | |
| 162 | + return registryPlacement | |
| 163 | + ? [oauth, { value: { kind: "apikey", placements: [registryPlacement] } }] | |
| 164 | + : [oauth]; | |
| 165 | + } | |
| 166 | + if (probe.requiresAuthentication) { | |
| 167 | + // The registry's exact placement beats the generic Bearer guess. | |
| 168 | + return [ | |
| 169 | + { | |
| 170 | + value: { | |
| 171 | + kind: "apikey", | |
| 172 | + placements: [ | |
| 173 | + registryPlacement ?? { carrier: "header", name: "Authorization", prefix: "Bearer " }, | |
| 174 | + ], | |
| 175 | + }, | |
| 176 | + label: "Detected", | |
| 177 | + }, | |
| 178 | + ]; | |
| 179 | + } | |
| 180 | + return [{ value: { kind: "none" }, label: "Detected" }]; | |
| 181 | +} | |
| @@ -1,6 +1,21 @@ | ||
| 1 | −import { lazy, type ComponentProps, type ComponentType } from "react"; | |
| 2 | −import type { IntegrationPlugin } from "@executor-js/sdk/client"; | |
| 1 | +import { lazy, useCallback, type ComponentProps, type ComponentType } from "react"; | |
| 2 | +import { useAtomSet } from "@effect/atom-react"; | |
| 3 | +import * as Exit from "effect/Exit"; | |
| 4 | +import type { | |
| 5 | + IntegrationPlugin, | |
| 6 | + IntegrationQuickAddInput, | |
| 7 | + IntegrationQuickAddResult, | |
| 8 | +} from "@executor-js/sdk/client"; | |
| 9 | +import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | |
| 10 | +import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; | |
| 11 | +import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | |
| 3 | 12 | import { mcpPresets } from "../sdk/presets"; |
| 13 | +import { addMcpServer, probeMcpEndpoint } from "./atoms"; | |
| 14 | +import { | |
| 15 | + mcpAuthMethodInputFromEditorValue, | |
| 16 | + mcpDetectedAuthSeeds, | |
| 17 | + mcpWireAuthInput, | |
| 18 | +} from "./auth-method-config"; | |
| 4 | 19 | |
| 5 | 20 | const importAdd = () => import("./AddMcpIntegration"); |
| 6 | 21 | const importEditSheet = () => import("./EditMcpIntegration"); |
| @@ -12,6 +27,51 @@ const LazyMcpAccountsPanel = lazy(importAccounts); · 5 unmodified lines | ||
| 12 | 27 | |
| 13 | 28 | type AddProps = ComponentProps<IntegrationPlugin["add"]>; |
| 14 | 29 | |
| 30 | +/** One-click add for a registry MCP row: probe, declare the auth methods the | |
| 31 | + * add page would have seeded (same policy — `mcpDetectedAuthSeeds`), and | |
| 32 | + * register. A failed probe means the configuration screen's job — it renders | |
| 33 | + * the failure with retry UX — so quick add reports `{ok: false}` instead of | |
| 34 | + * guessing. */ | |
| 35 | +function useMcpQuickAdd(): (input: IntegrationQuickAddInput) => Promise<IntegrationQuickAddResult> { | |
| 36 | + const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); | |
| 37 | + const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" }); | |
| 38 | + return useCallback( | |
| 39 | + async (input) => { | |
| 40 | + const probeExit = await doProbe({ payload: { endpoint: input.url } }); | |
| 41 | + if (Exit.isFailure(probeExit)) return { ok: false, reason: "probe failed" }; | |
| 42 | + const probe = probeExit.value; | |
| 43 | + const seeds = mcpDetectedAuthSeeds(probe, { | |
| 44 | + placement: input.authHeader ? placementFromHeaderPattern(input.authHeader) : null, | |
| 45 | + kind: input.authKind, | |
| 46 | + }); | |
| 47 | + const methods = seeds.map((seed) => | |
| 48 | + mcpWireAuthInput(mcpAuthMethodInputFromEditorValue(seed.value)), | |
| 49 | + ); | |
| 50 | + const slug = input.slug ? slugifyNamespace(input.slug) : undefined; | |
| 51 | + const addExit = await doAddServer({ | |
| 52 | + payload: { | |
| 53 | + transport: "remote" as const, | |
| 54 | + name: input.name, | |
| 55 | + endpoint: input.url, | |
| 56 | + ...(slug ? { slug } : {}), | |
| 57 | + ...(probe.instructions ? { description: probe.instructions } : {}), | |
| 58 | + authenticationTemplate: methods.length > 0 ? methods : [{ kind: "none" as const }], | |
| 59 | + // The probe reports when only legacy negotiation worked (a server | |
| 60 | + // that echoes the modern revision but breaks its contract); pin it | |
| 61 | + // so refreshes and tool calls use the same handshake. | |
| 62 | + ...(probe.versionNegotiation === "legacy" | |
| 63 | + ? { versionNegotiation: "legacy" as const } | |
| 64 | + : {}), | |
| 65 | + }, | |
| 66 | + reactivityKeys: integrationWriteKeys, | |
| 67 | + }); | |
| 68 | + if (Exit.isFailure(addExit)) return { ok: false, reason: "add failed" }; | |
| 69 | + return { ok: true, slug: String(addExit.value.slug) }; | |
| 70 | + }, | |
| 71 | + [doProbe, doAddServer], | |
| 72 | + ); | |
| 73 | +} | |
| 74 | + | |
| 15 | 75 | export interface McpIntegrationPluginOptions { |
| 16 | 76 | /** |
| 17 | 77 | * Enable the stdio transport in the add-integration UI (tab + presets). |
| @@ -32,11 +92,13 @@ export const createMcpIntegrationPlugin = ( · 14 unmodified lines | ||
| 32 | 92 | <LazyAddMcpIntegration {...props} allowStdio={allowStdio} /> |
| 33 | 93 | ); |
| 34 | 94 | |
| 35 | − const presets = allowStdio | |
| 95 | + const base = allowStdio | |
| 36 | 96 | ? mcpPresets |
| 37 | 97 | : mcpPresets.filter( |
| 38 | 98 | (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"), |
| 39 | 99 | ); |
| 100 | + // Built-ins are registry-listed; a deployment's custom presets would not be. | |
| 101 | + const presets = base.map((p) => ({ ...p, registryListed: true })); | |
| 40 | 102 | |
| 41 | 103 | return { |
| 42 | 104 | key: "mcp", |
| @@ -50,6 +112,7 @@ export const createMcpIntegrationPlugin = ( · 7 unmodified lines | ||
| 50 | 112 | void importEditSheet(); |
| 51 | 113 | void importAccounts(); |
| 52 | 114 | }, |
| 115 | + useQuickAdd: useMcpQuickAdd, | |
| 53 | 116 | }; |
| 54 | 117 | }; |
| 55 | 118 | |
| @@ -634,13 +634,14 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 633 unmodified lines | ||
| 634 | 634 | |
| 635 | 635 | const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); |
| 636 | 636 | |
| 637 | − // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable | |
| 638 | − // HTTP. SSE is a legacy-only transport; stdio negotiates per the | |
| 639 | − // integration's `versionNegotiation` (default legacy — see the stdio | |
| 640 | − // branch above). | |
| 637 | + // Auto-negotiate the 2026-07-28 era on Streamable HTTP unless the config | |
| 638 | + // pins `legacy` (for servers that echo the proposed revision and then | |
| 639 | + // violate its contract). SSE is a legacy-only transport; stdio negotiates | |
| 640 | + // per the integration's `versionNegotiation` (default legacy — see the | |
| 641 | + // stdio branch above). | |
| 641 | 642 | const connectStreamableHttp = connectClient({ |
| 642 | 643 | transport: "streamable-http", |
| 643 | − versionNegotiation: { mode: "auto" }, | |
| 644 | + ...(input.versionNegotiation === "legacy" ? {} : { versionNegotiation: { mode: "auto" } }), | |
| 644 | 645 | createTransport: (sdk) => |
| 645 | 646 | new sdk.client.StreamableHTTPClientTransport(endpoint, { |
| 646 | 647 | requestInit, |
| @@ -2,10 +2,11 @@ · 1 unmodified line | ||
| 2 | 2 | // MCP tool discovery — connect to an MCP server and list its tools |
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | −import { Duration, Effect, Option, Predicate } from "effect"; | |
| 5 | +import { Duration, Effect, Option, Predicate, Schema } from "effect"; | |
| 6 | 6 | |
| 7 | 7 | import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection"; |
| 8 | 8 | import { McpToolDiscoveryError } from "./errors"; |
| 9 | +import { createMcpConnector, type ConnectorInput } from "./connection"; | |
| 9 | 10 | import { httpStatusFromCause } from "./http-status"; |
| 10 | 11 | import { |
| 11 | 12 | decodeListToolsPage, |
| @@ -34,6 +35,14 @@ const DEFAULT_DISCOVER_TIMEOUT = Duration.seconds(15); · 22 unmodified lines | ||
| 34 | 35 | // Public API |
| 35 | 36 | // --------------------------------------------------------------------------- |
| 36 | 37 | |
| 38 | +/** The SDK rejects with an Error subclass carrying a stable `code`; decode | |
| 39 | + * the boundary instead of stringifying an unknown. */ | |
| 40 | +const SdkFailure = Schema.Struct({ | |
| 41 | + code: Schema.optional(Schema.String), | |
| 42 | + message: Schema.optional(Schema.String), | |
| 43 | +}); | |
| 44 | +const decodeSdkFailure = Schema.decodeUnknownOption(SdkFailure); | |
| 45 | + | |
| 37 | 46 | /** |
| 38 | 47 | * List every tool from an open MCP connection, following `nextCursor` |
| 39 | 48 | * pagination (spec: `tools/list` is a paginated operation — a single call |
| @@ -68,14 +77,23 @@ const listAllTools = ( · 28 unmodified lines | ||
| 68 | 77 | reauthorizationRequired: true, |
| 69 | 78 | }); |
| 70 | 79 | } |
| 80 | + const failure = Option.getOrNull(decodeSdkFailure(cause)); | |
| 81 | + // A modern-era connection whose server breaks the modern response | |
| 82 | + // contract means the server echoed our proposed revision without | |
| 83 | + // implementing it; callers can retry with legacy negotiation. | |
| 84 | + const modernContractViolation = | |
| 85 | + connection.client.getProtocolEra?.() === "modern" && failure?.code === "INVALID_RESULT"; | |
| 71 | 86 | const httpStatus = httpStatusFromCause(cause); |
| 72 | 87 | return new McpToolDiscoveryError({ |
| 73 | 88 | stage: "list_tools", |
| 74 | 89 | message: |
| 75 | − httpStatus === undefined | |
| 76 | − ? "Failed listing MCP tools" | |
| 77 | − : `Failed listing MCP tools (HTTP ${httpStatus})`, | |
| 90 | + failure?.message !== undefined | |
| 91 | + ? `Failed listing MCP tools: ${failure.message.slice(0, 300)}` | |
| 92 | + : httpStatus === undefined | |
| 93 | + ? "Failed listing MCP tools" | |
| 94 | + : `Failed listing MCP tools (HTTP ${httpStatus})`, | |
| 78 | 95 | ...(httpStatus === undefined ? {} : { httpStatus }), |
| 96 | + ...(modernContractViolation ? { modernContractViolation: true } : {}), | |
| 79 | 97 | }); |
| 80 | 98 | }, |
| 81 | 99 | }); |
| @@ -103,6 +121,39 @@ const listAllTools = ( · 21 unmodified lines | ||
| 103 | 121 | ); |
| 104 | 122 | }); |
| 105 | 123 | |
| 124 | +/** Discovery that survives version-echoing servers. Some servers answer the | |
| 125 | + * proposed 2026-07-28 revision affirmatively while emitting 2024-era results | |
| 126 | + * (Walmart's MCP), which the modern client rightly rejects. On that exact | |
| 127 | + * signature, retry once with legacy negotiation and report which one worked, | |
| 128 | + * so an add flow can pin `versionNegotiation: "legacy"` on the integration. */ | |
| 129 | +export const discoverToolsFromInput = ( | |
| 130 | + input: ConnectorInput, | |
| 131 | + timeoutMs?: number, | |
| 132 | +): Effect.Effect< | |
| 133 | + { readonly manifest: McpToolManifest; readonly versionNegotiation: "auto" | "legacy" | null }, | |
| 134 | + McpToolDiscoveryError | |
| 135 | +> => | |
| 136 | + discoverTools(createMcpConnector(input), timeoutMs).pipe( | |
| 137 | + Effect.map((manifest) => ({ | |
| 138 | + manifest, | |
| 139 | + versionNegotiation: input.transport === "stdio" ? null : (input.versionNegotiation ?? "auto"), | |
| 140 | + })), | |
| 141 | + Effect.catch((error) => { | |
| 142 | + const retriable = | |
| 143 | + error.modernContractViolation === true && | |
| 144 | + input.transport !== "stdio" && | |
| 145 | + input.versionNegotiation !== "legacy"; | |
| 146 | + if (!retriable) return Effect.fail(error); | |
| 147 | + return discoverTools( | |
| 148 | + createMcpConnector({ ...input, versionNegotiation: "legacy" }), | |
| 149 | + timeoutMs, | |
| 150 | + ).pipe( | |
| 151 | + Effect.map((manifest) => ({ manifest, versionNegotiation: "legacy" as const })), | |
| 152 | + Effect.withSpan("mcp.discover.legacy_retry"), | |
| 153 | + ); | |
| 154 | + }), | |
| 155 | + ); | |
| 156 | + | |
| 106 | 157 | /** |
| 107 | 158 | * Connect to an MCP server and discover all available tools. |
| 108 | 159 | * Returns the parsed manifest containing server metadata and tool entries. |
| @@ -43,6 +43,11 @@ export class McpToolDiscoveryError extends Schema.TaggedErrorClass<McpToolDiscov · 42 unmodified lines | ||
| 43 | 43 | /** HTTP status from the underlying connect or tools/list failure, when |
| 44 | 44 | * known. */ |
| 45 | 45 | httpStatus: Schema.optional(Schema.Number), |
| 46 | + /** The connection negotiated the modern (2026-07-28) era and the server | |
| 47 | + * then broke that revision's response contract — the signature of a | |
| 48 | + * server that echoes whatever protocol version is proposed. Retrying | |
| 49 | + * with legacy negotiation is expected to succeed. */ | |
| 50 | + modernContractViolation: Schema.optional(Schema.Boolean), | |
| 46 | 51 | /** The MCP OAuth provider reached the interactive authorization boundary. |
| 47 | 52 | * Catalog callers use this structural signal to request reconnect without |
| 48 | 53 | * parsing or exposing an upstream error message. */ |
| @@ -45,7 +45,7 @@ import { · 44 unmodified lines | ||
| 45 | 45 | import type { CodexPluginEntry } from "./codex-plugins"; |
| 46 | 46 | import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection"; |
| 47 | 47 | import { createMcpConnectionPool } from "./connection-pool"; |
| 48 | −import { discoverTools } from "./discover"; | |
| 48 | +import { discoverToolsFromInput } from "./discover"; | |
| 49 | 49 | import { |
| 50 | 50 | McpConnectionError, |
| 51 | 51 | type McpConnectionFailureKind, |
| @@ -218,6 +218,10 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 166 unmodified lines | ||
| 218 | 218 | /** Single-method shorthand (legacy callers). Ignored when |
| 219 | 219 | * `authenticationTemplate` is present. Defaults to none. */ |
| 220 | 220 | auth: Schema.optional(McpAuthShorthand), |
| 221 | + /** Pin protocol negotiation. `legacy` is for servers that echo the proposed | |
| 222 | + * 2026-07-28 revision and then violate its response contract; the probe | |
| 223 | + * reports when it had to fall back, and the add flow passes that through. */ | |
| 224 | + versionNegotiation: Schema.optional(McpStdioVersionNegotiation), | |
| 221 | 225 | }); |
| 222 | 226 | |
| 223 | 227 | const McpStdioServerInputSchema = Schema.Struct({ |
| @@ -305,6 +309,10 @@ const McpProbeEndpointOutputSchema = Schema.Struct({ · 81 unmodified lines | ||
| 305 | 309 | /** The server's `instructions` from initialize — prefill for the add form's |
| 306 | 310 | * description. Only available when the probe connected unauthenticated. */ |
| 307 | 311 | instructions: Schema.NullOr(Schema.String), |
| 312 | + /** Present when discovery succeeded: which protocol negotiation worked. | |
| 313 | + * `legacy` means the server echoed the modern revision and then broke its | |
| 314 | + * contract — the add should pin `versionNegotiation: "legacy"`. */ | |
| 315 | + versionNegotiation: Schema.optional(McpStdioVersionNegotiation), | |
| 308 | 316 | }); |
| 309 | 317 | |
| 310 | 318 | // --------------------------------------------------------------------------- |
| @@ -454,6 +462,7 @@ export const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfig · 143 unmodified lines | ||
| 454 | 462 | authenticationTemplate: input.authenticationTemplate |
| 455 | 463 | ? normalizeMcpAuthMethods(input.authenticationTemplate) |
| 456 | 464 | : [mcpAuthMethodFromShorthand(input.auth ?? { kind: "none" })], |
| 465 | + versionNegotiation: input.versionNegotiation, | |
| 457 | 466 | }; |
| 458 | 467 | }; |
| 459 | 468 | |
| @@ -695,6 +704,7 @@ const buildConnectorInput = ( · 235 unmodified lines | ||
| 695 | 704 | authProvider, |
| 696 | 705 | ...(authProvider === undefined ? {} : { staticOAuthBearer: true }), |
| 697 | 706 | httpClientLayer, |
| 707 | + versionNegotiation: config.versionNegotiation, | |
| 698 | 708 | }); |
| 699 | 709 | }; |
| 700 | 710 | |
| @@ -957,17 +967,17 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 256 unmodified lines | ||
| 957 | 967 | const probeHeaders = typeof input === "string" ? undefined : input.headers; |
| 958 | 968 | const probeQueryParams = typeof input === "string" ? undefined : input.queryParams; |
| 959 | 969 | |
| 960 | − const connector = createMcpConnector({ | |
| 970 | + const result = yield* discoverToolsFromInput({ | |
| 961 | 971 | transport: "remote", |
| 962 | 972 | endpoint: trimmed, |
| 963 | 973 | headers: probeHeaders, |
| 964 | 974 | queryParams: probeQueryParams, |
| 965 | 975 | httpClientLayer, |
| 966 | − }); | |
| 967 | − | |
| 968 | − const result = yield* discoverTools(connector).pipe( | |
| 969 | − Effect.map((m) => ({ ok: true as const, manifest: m })), | |
| 970 | − Effect.catch(() => Effect.succeed({ ok: false as const, manifest: null })), | |
| 976 | + }).pipe( | |
| 977 | + Effect.map((d) => ({ ok: true as const, ...d })), | |
| 978 | + Effect.catch(() => | |
| 979 | + Effect.succeed({ ok: false as const, manifest: null, versionNegotiation: null }), | |
| 980 | + ), | |
| 971 | 981 | Effect.withSpan("mcp.plugin.discover_tools"), |
| 972 | 982 | ); |
| 973 | 983 | |
| @@ -982,6 +992,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 8 unmodified lines | ||
| 982 | 992 | toolCount: result.manifest.tools.length, |
| 983 | 993 | serverName: result.manifest.server?.name ?? null, |
| 984 | 994 | instructions: result.manifest.server?.instructions ?? null, |
| 995 | + ...(result.versionNegotiation === "legacy" | |
| 996 | + ? { versionNegotiation: "legacy" as const } | |
| 997 | + : {}), | |
| 985 | 998 | } satisfies McpProbeResult; |
| 986 | 999 | } |
| 987 | 1000 | |
| @@ -1424,10 +1437,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 436 unmodified lines | ||
| 1424 | 1437 | template === null ? null : String(template), |
| 1425 | 1438 | allowStdio, |
| 1426 | 1439 | httpClientLayer, |
| 1427 | − ).pipe( | |
| 1428 | − Effect.map((ci) => createMcpConnector(ci)), | |
| 1429 | − Effect.result, | |
| 1430 | − ); | |
| 1440 | + ).pipe(Effect.result); | |
| 1431 | 1441 | |
| 1432 | 1442 | if (Result.isFailure(built)) { |
| 1433 | 1443 | return { |
| @@ -1437,7 +1447,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 3 unmodified lines | ||
| 1437 | 1447 | }; |
| 1438 | 1448 | } |
| 1439 | 1449 | |
| 1440 | − const discovered = yield* discoverTools(built.success).pipe( | |
| 1450 | + const discovered = yield* discoverToolsFromInput(built.success).pipe( | |
| 1441 | 1451 | Effect.result, |
| 1442 | 1452 | Effect.withSpan("mcp.plugin.discover_tools", { |
| 1443 | 1453 | attributes: { "mcp.connection.name": String(connection.name) }, |
| @@ -1460,7 +1470,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 16 unmodified lines | ||
| 1460 | 1470 | : {}), |
| 1461 | 1471 | }; |
| 1462 | 1472 | } |
| 1463 | − return { tools: discovered.success.tools.map(toToolDef) }; | |
| 1473 | + return { tools: discovered.success.manifest.tools.map(toToolDef) }; | |
| 1464 | 1474 | }).pipe( |
| 1465 | 1475 | Effect.withSpan("mcp.plugin.resolve_tools", { |
| 1466 | 1476 | attributes: { "mcp.connection.name": String(connection.name) }, |
| @@ -1676,13 +1686,11 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 209 unmodified lines | ||
| 1676 | 1686 | const name = parsed.value.hostname || "mcp"; |
| 1677 | 1687 | const slug = deriveMcpNamespace({ endpoint: trimmed }); |
| 1678 | 1688 | |
| 1679 | − const connector = createMcpConnector({ | |
| 1689 | + const connected = yield* discoverToolsFromInput({ | |
| 1680 | 1690 | transport: "remote", |
| 1681 | 1691 | endpoint: trimmed, |
| 1682 | 1692 | httpClientLayer, |
| 1683 | − }); | |
| 1684 | − | |
| 1685 | − const connected = yield* discoverTools(connector).pipe( | |
| 1693 | + }).pipe( | |
| 1686 | 1694 | Effect.map(() => true), |
| 1687 | 1695 | Effect.catch(() => Effect.succeed(false)), |
| 1688 | 1696 | Effect.withSpan("mcp.plugin.discover_tools"), |
| @@ -1794,9 +1802,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 105 unmodified lines | ||
| 1794 | 1802 | credential.template === null ? null : String(credential.template), |
| 1795 | 1803 | allowStdio, |
| 1796 | 1804 | options?.httpClientLayer ?? ctx.httpClientLayer, |
| 1797 | − ).pipe(Effect.map((ci) => createMcpConnector(ci))); | |
| 1805 | + ); | |
| 1798 | 1806 | |
| 1799 | − return yield* discoverTools(connector).pipe( | |
| 1807 | + return yield* discoverToolsFromInput(connector).pipe( | |
| 1800 | 1808 | Effect.map( |
| 1801 | 1809 | () => |
| 1802 | 1810 | ({ status: "healthy" as const, checkedAt: Date.now() }) satisfies HealthCheckResult, |
| @@ -57,14 +57,6 @@ const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map( · 56 unmodified lines | ||
| 57 | 57 | })); |
| 58 | 58 | |
| 59 | 59 | export const mcpPresets: readonly McpPreset[] = [ |
| 60 | − { | |
| 61 | − id: "emulate-mcp", | |
| 62 | − name: "Emulate MCP", | |
| 63 | − summary: "Deterministic MCP fixtures for validating native text and image content.", | |
| 64 | − url: "https://emulators.dev/mcp/query/mcp?token=demo-token", | |
| 65 | − endpoint: "https://emulators.dev/mcp/query/mcp?token=demo-token", | |
| 66 | − icon: "https://integrations.sh/logo/emulators.dev", | |
| 67 | − }, | |
| 68 | 60 | { |
| 69 | 61 | id: "deepwiki", |
| 70 | 62 | name: "DeepWiki", |
| @@ -231,6 +231,12 @@ export const McpRemoteIntegrationConfig = Schema.Struct({ · 230 unmodified lines | ||
| 231 | 231 | /** Declared auth methods — how a connection's values are rendered onto |
| 232 | 232 | * requests. A connection's `template` picks one by slug. */ |
| 233 | 233 | authenticationTemplate: Schema.Array(McpAuthMethod), |
| 234 | + /** Protocol negotiation. Remote defaults to `auto` (2026-07-28 era). | |
| 235 | + * `legacy` pins servers that ECHO the proposed revision and then violate | |
| 236 | + * its response contract — Walmart's MCP answers "2026-07-28" to any | |
| 237 | + * proposal while emitting 2024-era results, which the modern client | |
| 238 | + * rightly rejects. */ | |
| 239 | + versionNegotiation: Schema.optional(McpStdioVersionNegotiation), | |
| 234 | 240 | }); |
| 235 | 241 | export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type; |
| 236 | 242 | |
| @@ -82,6 +82,9 @@ const AddSpecPayload = Schema.Struct({ · 81 unmodified lines | ||
| 82 | 82 | name: Schema.optional(Schema.String), |
| 83 | 83 | description: Schema.optional(Schema.String), |
| 84 | 84 | baseUrl: Schema.optional(Schema.String), |
| 85 | + /** The product's domain when the caller knew it (a registry row names | |
| 86 | + * notion.com) — display identity when the spec lives on a code host. */ | |
| 87 | + displayDomain: Schema.optional(Schema.String), | |
| 85 | 88 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 86 | 89 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 87 | 90 | specFormat: Schema.optional(Schema.String), |
| @@ -63,6 +63,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 62 unmodified lines | ||
| 63 | 63 | name: payload.name, |
| 64 | 64 | description: payload.description, |
| 65 | 65 | baseUrl: payload.baseUrl, |
| 66 | + displayDomain: payload.displayDomain, | |
| 66 | 67 | headers: payload.headers ? { ...payload.headers } : undefined, |
| 67 | 68 | queryParams: payload.queryParams ? { ...payload.queryParams } : undefined, |
| 68 | 69 | specFormat: payload.specFormat, |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | 2 | import { Effect, Option, Schema } from "effect"; |
| 3 | +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | |
| 3 | 4 | import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; |
| 4 | 5 | |
| 5 | 6 | import { |
| @@ -7,6 +8,7 @@ import { · 1 unmodified line | ||
| 7 | 8 | convertGoogleDiscoveryToOpenApi, |
| 8 | 9 | isGoogleDiscoveryUrl, |
| 9 | 10 | normalizeGoogleDiscoveryUrl, |
| 11 | + fetchGoogleDiscoveryDocument, | |
| 10 | 12 | } from "./discovery"; |
| 11 | 13 | import { googleOAuthConsentScopesForPreset } from "./service-policy"; |
| 12 | 14 | import { extract, parse } from "@executor-js/plugin-openapi"; |
| @@ -1669,3 +1671,29 @@ it.effect("marks People API semantic inputs as required", () => · 1656 unmodified lines | ||
| 1669 | 1671 | }); |
| 1670 | 1672 | }), |
| 1671 | 1673 | ); |
| 1674 | + | |
| 1675 | +it("falls back to the service-hosted Discovery URL when the central directory has no entry", async () => { | |
| 1676 | + // Google Ads is not listed in the central directory: normalization maps it | |
| 1677 | + // onto www.googleapis.com for identity, and that URL 404s. The fetch must | |
| 1678 | + // still succeed by falling back to the host the caller named. | |
| 1679 | + const seen: string[] = []; | |
| 1680 | + const stub = HttpClient.make((request) => { | |
| 1681 | + seen.push(request.url); | |
| 1682 | + return Effect.succeed( | |
| 1683 | + request.url.includes("www.googleapis.com") | |
| 1684 | + ? HttpClientResponse.fromWeb(request, new Response("nope", { status: 404 })) | |
| 1685 | + : HttpClientResponse.fromWeb( | |
| 1686 | + request, | |
| 1687 | + new Response('{"name":"googleads"}', { status: 200 }), | |
| 1688 | + ), | |
| 1689 | + ); | |
| 1690 | + }); | |
| 1691 | + const text = await Effect.runPromise( | |
| 1692 | + fetchGoogleDiscoveryDocument( | |
| 1693 | + "https://googleads.googleapis.com/$discovery/rest?version=v24", | |
| 1694 | + ).pipe(Effect.provideService(HttpClient.HttpClient, stub)), | |
| 1695 | + ); | |
| 1696 | + expect(text).toBe('{"name":"googleads"}'); | |
| 1697 | + expect(seen[0]).toContain("www.googleapis.com"); | |
| 1698 | + expect(seen[1]).toBe("https://googleads.googleapis.com/$discovery/rest?version=v24"); | |
| 1699 | +}); | |
| @@ -343,6 +343,25 @@ export const isGoogleDiscoveryUrl = (url: string): boolean => { · 342 unmodified lines | ||
| 343 | 343 | return normalizeGoogleDiscoveryUrl(url) !== null; |
| 344 | 344 | }; |
| 345 | 345 | |
| 346 | +/** The service's own Discovery endpoint, for a URL that named it. Normalization | |
| 347 | + * canonicalizes most services onto the central directory, which is right for | |
| 348 | + * identity but NOT universally fetchable: services outside the directory | |
| 349 | + * (Google Ads) answer only on their own host. */ | |
| 350 | +const serviceHostedDiscoveryUrl = (discoveryUrl: string): string | null => { | |
| 351 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() rejects malformed input | |
| 352 | + try { | |
| 353 | + const parsed = new URL(discoveryUrl.trim()); | |
| 354 | + const host = parsed.hostname.toLowerCase(); | |
| 355 | + if (host === "www.googleapis.com" || !host.endsWith(".googleapis.com")) return null; | |
| 356 | + if (!["/$discovery/rest", "/$discovery/rest/"].includes(parsed.pathname)) return null; | |
| 357 | + const version = parsed.searchParams.get("version")?.trim(); | |
| 358 | + if (!version || !DISCOVERY_VERSION_RE.test(version)) return null; | |
| 359 | + return `https://${host}/$discovery/rest?version=${version}`; | |
| 360 | + } catch { | |
| 361 | + return null; | |
| 362 | + } | |
| 363 | +}; | |
| 364 | + | |
| 346 | 365 | export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")( |
| 347 | 366 | function* (discoveryUrl: string, credentials?: SpecFetchCredentials) { |
| 348 | 367 | const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl); |
| @@ -353,35 +372,50 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov · 4 unmodified lines | ||
| 353 | 372 | }); |
| 354 | 373 | } |
| 355 | 374 | const client = yield* HttpClient.HttpClient; |
| 356 | − const requestUrl = new URL(normalizedDiscoveryUrl); | |
| 357 | − for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { | |
| 358 | − requestUrl.searchParams.set(name, value); | |
| 359 | − } | |
| 360 | − let request = HttpClientRequest.get(requestUrl.toString()).pipe( | |
| 361 | − HttpClientRequest.setHeader("Accept", "application/json, */*"), | |
| 362 | − ); | |
| 363 | − for (const [name, value] of Object.entries(credentials?.headers ?? {})) { | |
| 364 | − request = HttpClientRequest.setHeader(request, name, value); | |
| 365 | − } | |
| 366 | − const response = yield* client.execute(request).pipe( | |
| 367 | − Effect.mapError( | |
| 368 | − () => | |
| 369 | − new OpenApiParseError({ | |
| 370 | − message: "Failed to fetch Google Discovery document", | |
| 371 | − }), | |
| 372 | − ), | |
| 373 | − ); | |
| 374 | − if (response.status < 200 || response.status >= 300) { | |
| 375 | − return yield* new OpenApiParseError({ | |
| 376 | − message: `Failed to fetch Google Discovery document: HTTP ${response.status}`, | |
| 375 | + | |
| 376 | + const attempt = (target: string) => | |
| 377 | + Effect.gen(function* () { | |
| 378 | + const requestUrl = new URL(target); | |
| 379 | + for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { | |
| 380 | + requestUrl.searchParams.set(name, value); | |
| 381 | + } | |
| 382 | + let request = HttpClientRequest.get(requestUrl.toString()).pipe( | |
| 383 | + HttpClientRequest.setHeader("Accept", "application/json, */*"), | |
| 384 | + ); | |
| 385 | + for (const [name, value] of Object.entries(credentials?.headers ?? {})) { | |
| 386 | + request = HttpClientRequest.setHeader(request, name, value); | |
| 387 | + } | |
| 388 | + const response = yield* client | |
| 389 | + .execute(request) | |
| 390 | + .pipe( | |
| 391 | + Effect.mapError( | |
| 392 | + () => new OpenApiParseError({ message: "Failed to fetch Google Discovery document" }), | |
| 393 | + ), | |
| 394 | + ); | |
| 395 | + if (response.status < 200 || response.status >= 300) { | |
| 396 | + return yield* new OpenApiParseError({ | |
| 397 | + message: `Failed to fetch Google Discovery document: HTTP ${response.status}`, | |
| 398 | + }); | |
| 399 | + } | |
| 400 | + return yield* response.text.pipe( | |
| 401 | + Effect.mapError( | |
| 402 | + () => | |
| 403 | + new OpenApiParseError({ message: "Failed to read Google Discovery document body" }), | |
| 404 | + ), | |
| 405 | + ); | |
| 377 | 406 | }); |
| 378 | − } | |
| 379 | − return yield* response.text.pipe( | |
| 380 | − Effect.mapError( | |
| 381 | − () => | |
| 382 | − new OpenApiParseError({ | |
| 383 | − message: "Failed to read Google Discovery document body", | |
| 384 | − }), | |
| 407 | + | |
| 408 | + // Normalization maps a service-hosted URL onto the central directory for a | |
| 409 | + // STABLE IDENTITY, but the directory does not list every service — Google | |
| 410 | + // Ads answers only on googleads.googleapis.com, so the canonical form 404s | |
| 411 | + // for a URL the user pasted that works. Fall back to the host they named | |
| 412 | + // rather than maintaining an allowlist of every such service forever. | |
| 413 | + const serviceHosted = serviceHostedDiscoveryUrl(discoveryUrl); | |
| 414 | + return yield* attempt(normalizedDiscoveryUrl).pipe( | |
| 415 | + Effect.catch((error) => | |
| 416 | + serviceHosted && serviceHosted !== normalizedDiscoveryUrl | |
| 417 | + ? attempt(serviceHosted) | |
| 418 | + : Effect.fail(error), | |
| 385 | 419 | ), |
| 386 | 420 | ); |
| 387 | 421 | }, |
| @@ -346,6 +346,7 @@ export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets · 345 unmodified lines | ||
| 346 | 346 | ...(preset.featured ? { featured: preset.featured } : {}), |
| 347 | 347 | family: "google", |
| 348 | 348 | specFormat: "google-discovery", |
| 349 | + registryListed: true, | |
| 349 | 350 | defaultSlug: googleServiceSlug(preset.id), |
| 350 | 351 | authTemplate: googleCatalogAuthTemplate(preset.id), |
| 351 | 352 | ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}), |
| @@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab · 3 unmodified lines | ||
| 4 | 4 | import { createExecutor, IntegrationSlug } from "@executor-js/sdk"; |
| 5 | 5 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 6 | 6 | import { openApiPlugin, parse } from "@executor-js/plugin-openapi"; |
| 7 | +import { resolveSpecFormatAdapter } from "../../sdk/spec-format"; | |
| 7 | 8 | import type { AuthenticationInput } from "@executor-js/plugin-openapi"; |
| 8 | 9 | |
| 9 | 10 | import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; |
| @@ -290,3 +291,30 @@ it.effect("preserves a Google preset's full consumer consent boundary when refre · 280 unmodified lines | ||
| 290 | 291 | ); |
| 291 | 292 | }), |
| 292 | 293 | ); |
| 294 | + | |
| 295 | +it.effect("a hand-pasted Discovery URL selects the adapter without a preset", () => | |
| 296 | + Effect.gen(function* () { | |
| 297 | + const detected = yield* resolveSpecFormatAdapter( | |
| 298 | + [googleDiscoveryAdapter], | |
| 299 | + undefined, | |
| 300 | + GMAIL_URL, | |
| 301 | + ); | |
| 302 | + expect(detected?.id).toBe("google-discovery"); | |
| 303 | + | |
| 304 | + const plainOpenApi = yield* resolveSpecFormatAdapter( | |
| 305 | + [googleDiscoveryAdapter], | |
| 306 | + undefined, | |
| 307 | + "https://api.example.com/openapi.json", | |
| 308 | + ); | |
| 309 | + expect(plainOpenApi).toBeNull(); | |
| 310 | + | |
| 311 | + // An explicit format id still wins over detection, and an unknown one | |
| 312 | + // still fails rather than silently falling back. | |
| 313 | + const explicit = yield* resolveSpecFormatAdapter( | |
| 314 | + [googleDiscoveryAdapter], | |
| 315 | + "google-discovery", | |
| 316 | + "https://api.example.com/openapi.json", | |
| 317 | + ); | |
| 318 | + expect(explicit?.id).toBe("google-discovery"); | |
| 319 | + }), | |
| 320 | +); | |
| @@ -6,6 +6,7 @@ import { type DerivedIdentity, type SpecFormatAdapter } from "../../sdk/spec-for · 5 unmodified lines | ||
| 6 | 6 | import { |
| 7 | 7 | convertGoogleDiscoveryBundleToOpenApi, |
| 8 | 8 | fetchGoogleDiscoveryDocument, |
| 9 | + isGoogleDiscoveryUrl, | |
| 9 | 10 | normalizeGoogleDiscoveryUrl, |
| 10 | 11 | } from "./discovery"; |
| 11 | 12 | |
| @@ -47,6 +48,7 @@ export const deriveGoogleDiscoveryIdentity = (doc: unknown): DerivedIdentity | n · 35 unmodified lines | ||
| 47 | 48 | |
| 48 | 49 | export const googleDiscoveryAdapter: SpecFormatAdapter = { |
| 49 | 50 | id: "google-discovery", |
| 51 | + detectsUrl: isGoogleDiscoveryUrl, | |
| 50 | 52 | fetch: (input) => |
| 51 | 53 | Effect.gen(function* () { |
| 52 | 54 | const documents = yield* Effect.forEach( |
| @@ -569,6 +569,7 @@ export const microsoftCatalog: readonly IntegrationPreset[] = microsoftGraphScop · 568 unmodified lines | ||
| 569 | 569 | ...(preset.featured ? { featured: preset.featured } : {}), |
| 570 | 570 | family: "microsoft", |
| 571 | 571 | specFormat: "microsoft-graph", |
| 572 | + registryListed: true, | |
| 572 | 573 | defaultSlug: microsoftServiceSlug(preset.id), |
| 573 | 574 | authTemplate: microsoftGraphCatalogAuthTemplate(preset), |
| 574 | 575 | ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}), |
| @@ -221,3 +221,22 @@ it.effect("stream-previews a Graph selection without a whole-document parse", () · 220 unmodified lines | ||
| 221 | 221 | ]); |
| 222 | 222 | }), |
| 223 | 223 | ); |
| 224 | + | |
| 225 | +it("detects Graph sources so a hand-pasted URL does not fall to the plain path", () => { | |
| 226 | + const detects = (url: string) => microsoftGraphAdapter.detectsUrl?.(url) === true; | |
| 227 | + // The monolith is the case that matters: on the plain path it is 43 MB and | |
| 228 | + // trips the whole-document parse limit. | |
| 229 | + expect(detects(`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=mail`)).toBe(true); | |
| 230 | + expect(detects(MICROSOFT_GRAPH_OPENAPI_URL)).toBe(true); | |
| 231 | + expect( | |
| 232 | + detects( | |
| 233 | + "https://github.com/UsefulSoftwareCo/executor/releases/download/graph-slices/mail.yaml", | |
| 234 | + ), | |
| 235 | + ).toBe(true); | |
| 236 | + // Already-sliced hosted specs are self-contained and parse on the plain | |
| 237 | + // path; claiming them would route them through the slice builder for nothing. | |
| 238 | + expect(detects("https://integrations.sh/specs/microsoft-graph/mail.json")).toBe(false); | |
| 239 | + expect( | |
| 240 | + detects("https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json"), | |
| 241 | + ).toBe(false); | |
| 242 | +}); | |
| @@ -3,6 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import type { SpecFormatAdapter } from "../../sdk/spec-format"; |
| 4 | 4 | |
| 5 | 5 | import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph"; |
| 6 | +import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets"; | |
| 6 | 7 | import { microsoftGraphPresetIdsForSliceAsset, microsoftGraphSliceAssetFromUrl } from "./slices"; |
| 7 | 8 | |
| 8 | 9 | const fragmentPresetIds = (hash: string): readonly string[] => |
| @@ -40,8 +41,28 @@ const graphCatalogSelection = ( · 31 unmodified lines | ||
| 40 | 41 | }; |
| 41 | 42 | }; |
| 42 | 43 | |
| 44 | +/** Recognizes the Graph sources this adapter exists to handle: Microsoft's | |
| 45 | + * published monolith and executor's slice assets, with or without a | |
| 46 | + * `#preset=` selector. Without this, pasting one of those URLs by hand falls | |
| 47 | + * to the plain OpenAPI path, which then chokes on the 43 MB monolith — the | |
| 48 | + * adapter only ever engaged when a preset supplied `specFormat`. */ | |
| 49 | +const detectsGraphUrl = (url: string): boolean => { | |
| 50 | + if (!URL.canParse(url)) return false; | |
| 51 | + const parsed = new URL(url); | |
| 52 | + parsed.hash = ""; | |
| 53 | + const bare = parsed.toString(); | |
| 54 | + return ( | |
| 55 | + bare === MICROSOFT_GRAPH_OPENAPI_URL || | |
| 56 | + microsoftGraphSliceAssetFromUrl(bare) !== null || | |
| 57 | + /^https:\/\/raw\.githubusercontent\.com\/microsoftgraph\/msgraph-metadata\/.*\/openapi\.ya?ml$/.test( | |
| 58 | + bare, | |
| 59 | + ) | |
| 60 | + ); | |
| 61 | +}; | |
| 62 | + | |
| 43 | 63 | export const microsoftGraphAdapter: SpecFormatAdapter = { |
| 44 | 64 | id: "microsoft-graph", |
| 65 | + detectsUrl: detectsGraphUrl, | |
| 45 | 66 | fetch: (input) => |
| 46 | 67 | buildMicrosoftGraphOpenApiSpec( |
| 47 | 68 | graphCatalogSelection(input.urls[0]), |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | ||
| 25 | 25 | type AuthMethodRow, |
| 26 | 26 | type AuthMethodSeed, |
| 27 | 27 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 28 | +import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | |
| 28 | 29 | import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; |
| 29 | 30 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 30 | 31 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| @@ -170,18 +171,40 @@ export default function AddOpenApiIntegration(props: { · 139 unmodified lines | ||
| 170 | 171 | initialUrl?: string; |
| 171 | 172 | initialPreset?: string; |
| 172 | 173 | initialNamespace?: string; |
| 174 | + initialAuthHeader?: string; | |
| 175 | + initialAuthNote?: string; | |
| 176 | + initialSpecOverrides?: string; | |
| 173 | 177 | }) { |
| 174 | 178 | const integrationPlugins = useIntegrationPlugins(); |
| 175 | 179 | const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi"); |
| 176 | 180 | const openApiPresets = openApiPlugin?.presets; |
| 177 | − const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); | |
| 181 | + // A `?preset=` deep link is self-sufficient: the preset's own URL seeds the | |
| 182 | + // field. The connect dialog used to pass `&url=` alongside, which hid that | |
| 183 | + // this never worked on its own — preset links from agents and docs carry | |
| 184 | + // only the id. | |
| 185 | + const [specUrl, setSpecUrl] = useState( | |
| 186 | + () => | |
| 187 | + props.initialUrl ?? | |
| 188 | + openApiPresets?.find((preset) => preset.id === props.initialPreset)?.url ?? | |
| 189 | + "", | |
| 190 | + ); | |
| 178 | 191 | const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null); |
| 179 | 192 | const [analyzing, setAnalyzing] = useState(false); |
| 180 | 193 | const [analyzeError, setAnalyzeError] = useState<string | null>(null); |
| 181 | 194 | |
| 182 | 195 | const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl); |
| 183 | 196 | const presetSpecOverrides = decodeOpenApiSpecOverrides(activePreset?.specOverrides); |
| 184 | − const specOverridesText = specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides); | |
| 197 | + // Registry-declared overrides — how a vendor's published document gets | |
| 198 | + // improved without hosting a fork (Neon's console session cookies posing as | |
| 199 | + // security schemes). A preset's own overrides win; the user's draft wins | |
| 200 | + // over both, and the editor shows exactly what will apply. | |
| 201 | + const registrySpecOverrides = useMemo(() => { | |
| 202 | + if (!props.initialSpecOverrides) return undefined; | |
| 203 | + const parsed = parseSpecOverridesText(props.initialSpecOverrides); | |
| 204 | + return parsed.ok ? parsed.value : undefined; | |
| 205 | + }, [props.initialSpecOverrides]); | |
| 206 | + const specOverridesText = | |
| 207 | + specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides ?? registrySpecOverrides); | |
| 185 | 208 | |
| 186 | 209 | // After analysis |
| 187 | 210 | const [preview, setPreview] = useState<SpecPreviewSummary | null>(null); |
| @@ -297,14 +320,26 @@ export default function AddOpenApiIntegration(props: { · 109 unmodified lines | ||
| 297 | 320 | ...(preview?.headerPresets ?? []).map((preset) => preset.label), |
| 298 | 321 | ...(preview?.oauth2Presets ?? []).map((preset) => preset.label), |
| 299 | 322 | ]; |
| 300 | − return authenticationTemplate.map( | |
| 323 | + const detected = authenticationTemplate.map( | |
| 301 | 324 | (template: Authentication, index: number): AuthMethodSeed => ({ |
| 302 | 325 | value: editorValueFromAuthentication(template), |
| 303 | 326 | slug: String(template.slug), |
| 304 | 327 | ...(labels[index] !== undefined ? { label: labels[index] } : {}), |
| 305 | 328 | }), |
| 306 | 329 | ); |
| 307 | − }, [preview, authenticationTemplate]); | |
| 330 | + if (preview === null) return detected; | |
| 331 | + // The registry's declared credential placement fills what the document | |
| 332 | + // leaves out. GitHub's official description famously declares no | |
| 333 | + // securitySchemes at all, and even with the OAuth preset template a PAT | |
| 334 | + // bearer header is how most calls actually authenticate — so the key | |
| 335 | + // method is offered alongside OAuth. A spec-declared key method wins over | |
| 336 | + // the registry's version of the same fact. | |
| 337 | + const placement = props.initialAuthHeader | |
| 338 | + ? placementFromHeaderPattern(props.initialAuthHeader) | |
| 339 | + : null; | |
| 340 | + if (!placement || detected.some((seed) => seed.value.kind === "apikey")) return detected; | |
| 341 | + return [...detected, { value: { kind: "apikey", placements: [placement] } }]; | |
| 342 | + }, [preview, authenticationTemplate, props.initialAuthHeader]); | |
| 308 | 343 | const authMethodList = useAuthMethodList(authMethodSeeds); |
| 309 | 344 | |
| 310 | 345 | // The methods to register, mapped back to stored `Authentication[]`. Drops |
| @@ -597,10 +632,14 @@ export default function AddOpenApiIntegration(props: { · 286 unmodified lines | ||
| 597 | 632 | <AuthMethodListEditor |
| 598 | 633 | list={authMethodList} |
| 599 | 634 | emptyHint="No authentication detected. Add a method, or add the integration without auth and connect an account from the integration page later." |
| 600 | − footerHint="Every method here is registered with the integration. Connect an account from the integration page after adding." | |
| 635 | + footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page." | |
| 601 | 636 | /> |
| 602 | 637 | )} |
| 603 | 638 | |
| 639 | + {preview && props.initialAuthNote ? ( | |
| 640 | + <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p> | |
| 641 | + ) : null} | |
| 642 | + | |
| 604 | 643 | {preview ? ( |
| 605 | 644 | <AddOpenApiHealthCheckSection |
| 606 | 645 | presetHealthCheck={activePreset?.healthCheck} |
| @@ -0,0 +1,241 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Option } from "effect"; | |
| 3 | + | |
| 4 | +import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides"; | |
| 5 | +import { Exit } from "effect"; | |
| 6 | + | |
| 7 | +import { | |
| 8 | + composeQuickAddAuth, | |
| 9 | + performQuickAdd, | |
| 10 | + quickAddRequestPayloads, | |
| 11 | + quickAddSpecPlan, | |
| 12 | + type QuickAddDeps, | |
| 13 | +} from "./integration-plugin"; | |
| 14 | + | |
| 15 | +// The quick add's composition is where review found bugs twice: first it | |
| 16 | +// suppressed spec-declared methods, then it derived auth from the unmodified | |
| 17 | +// document while adding the overridden one. These pin the contract. | |
| 18 | + | |
| 19 | +const scopeOverride = decodeOpenApiSpecOverrides([ | |
| 20 | + { | |
| 21 | + op: "replace", | |
| 22 | + path: "/components/securitySchemes/OAuth2/flows/authorizationCode/scopes", | |
| 23 | + value: { "files:read": "" }, | |
| 24 | + }, | |
| 25 | +])!; | |
| 26 | + | |
| 27 | +describe("quickAddSpecPlan", () => { | |
| 28 | + it("uses the same effective overrides for preview and add, preset first", () => { | |
| 29 | + const registryOverrides = decodeOpenApiSpecOverrides([ | |
| 30 | + { op: "remove", path: "/components/securitySchemes/Cookie" }, | |
| 31 | + ]); | |
| 32 | + const plan = quickAddSpecPlan( | |
| 33 | + { | |
| 34 | + id: "figma", | |
| 35 | + name: "Figma", | |
| 36 | + summary: "", | |
| 37 | + specFormat: "plain-ish", | |
| 38 | + specOverrides: [...scopeOverride], | |
| 39 | + }, | |
| 40 | + registryOverrides, | |
| 41 | + ); | |
| 42 | + // Preset overrides win over the registry's, mirroring the full add page. | |
| 43 | + expect(plan.specOverrides).toEqual(scopeOverride); | |
| 44 | + expect(plan.specFormat).toBe("plain-ish"); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + it("an explicitly EMPTY preset override list suppresses the registry's", () => { | |
| 48 | + // Presence-based, like the full page's `presetOverrides ?? registry`: | |
| 49 | + // an empty list is a decision, not an absence. | |
| 50 | + const registryOverrides = decodeOpenApiSpecOverrides([ | |
| 51 | + { op: "remove", path: "/components/securitySchemes/Cookie" }, | |
| 52 | + ]); | |
| 53 | + const plan = quickAddSpecPlan( | |
| 54 | + { id: "custom", name: "Custom", summary: "", specOverrides: [] }, | |
| 55 | + registryOverrides, | |
| 56 | + ); | |
| 57 | + expect(plan.specOverrides).toBeUndefined(); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + it("falls through to registry overrides when the preset has none", () => { | |
| 61 | + const registryOverrides = decodeOpenApiSpecOverrides([ | |
| 62 | + { op: "remove", path: "/components/securitySchemes/Cookie" }, | |
| 63 | + ]); | |
| 64 | + const plan = quickAddSpecPlan(undefined, registryOverrides); | |
| 65 | + expect(plan.specOverrides).toEqual(registryOverrides); | |
| 66 | + expect(plan.specFormat).toBeUndefined(); | |
| 67 | + }); | |
| 68 | +}); | |
| 69 | + | |
| 70 | +describe("composeQuickAddAuth", () => { | |
| 71 | + const registryPlacement = { | |
| 72 | + carrier: "header", | |
| 73 | + name: "Authorization", | |
| 74 | + prefix: "Bearer ", | |
| 75 | + } as const; | |
| 76 | + | |
| 77 | + it("keeps override-derived OAuth scopes and appends the registry key", () => { | |
| 78 | + // The preview reflects the OVERRIDDEN document (quickAddSpecPlan sends | |
| 79 | + // the same overrides to preview): its oauth preset carries the replaced | |
| 80 | + // scopes. The composed template must retain them verbatim and add the | |
| 81 | + // registry header only because no key method was detected. | |
| 82 | + const template = composeQuickAddAuth([], registryPlacement, { | |
| 83 | + headerPresets: [], | |
| 84 | + oauth2Presets: [ | |
| 85 | + { | |
| 86 | + label: "OAuth2", | |
| 87 | + securitySchemeName: "OAuth2", | |
| 88 | + flow: "authorizationCode" as const, | |
| 89 | + authorizationUrl: Option.some("https://example.com/authorize"), | |
| 90 | + tokenUrl: "https://example.com/token", | |
| 91 | + resource: Option.none(), | |
| 92 | + refreshUrl: Option.none(), | |
| 93 | + // The overridden document's scopes — the whole point: the preview | |
| 94 | + // reflects the SAME effective spec the add stores. | |
| 95 | + scopes: { "files:read": "" }, | |
| 96 | + identityScopes: [] as const, | |
| 97 | + }, | |
| 98 | + ], | |
| 99 | + servers: [{ url: "https://api.example.com" }], | |
| 100 | + }); | |
| 101 | + expect(template).toHaveLength(2); | |
| 102 | + const oauth = template.find((method) => "kind" in method && method.kind === "oauth2"); | |
| 103 | + expect(oauth && "scopes" in oauth ? oauth.scopes : undefined).toEqual(["files:read"]); | |
| 104 | + const key = template.find((method) => !("kind" in method) || method.kind !== "oauth2"); | |
| 105 | + expect(JSON.stringify(key)).toContain("Authorization"); | |
| 106 | + }); | |
| 107 | + | |
| 108 | + it("does not append the registry key when the spec already declares one", () => { | |
| 109 | + const template = composeQuickAddAuth([], registryPlacement, { | |
| 110 | + headerPresets: [ | |
| 111 | + { | |
| 112 | + label: "API key", | |
| 113 | + headers: { "X-API-Key": null }, | |
| 114 | + secretHeaders: ["X-API-Key"], | |
| 115 | + secretQueryParams: [], | |
| 116 | + }, | |
| 117 | + ], | |
| 118 | + oauth2Presets: [], | |
| 119 | + servers: [{ url: "https://api.example.com" }], | |
| 120 | + }); | |
| 121 | + expect(template).toHaveLength(1); | |
| 122 | + expect(JSON.stringify(template[0])).toContain("X-API-Key"); | |
| 123 | + }); | |
| 124 | +}); | |
| 125 | + | |
| 126 | +describe("quickAddRequestPayloads", () => { | |
| 127 | + it("the preview and the add carry the identical spec plan", () => { | |
| 128 | + // The regression contract itself: reverting either call site to a bare | |
| 129 | + // payload makes these fields diverge and this test fail. | |
| 130 | + const plan = quickAddSpecPlan( | |
| 131 | + { | |
| 132 | + id: "figma", | |
| 133 | + name: "Figma", | |
| 134 | + summary: "", | |
| 135 | + specFormat: "fmt", | |
| 136 | + specOverrides: [...scopeOverride], | |
| 137 | + }, | |
| 138 | + undefined, | |
| 139 | + ); | |
| 140 | + const requests = quickAddRequestPayloads( | |
| 141 | + { url: "https://example.com/spec.yaml", name: "Figma API", domain: "figma.com" }, | |
| 142 | + "figma_api", | |
| 143 | + plan, | |
| 144 | + ); | |
| 145 | + expect(requests.preview.specFormat).toBe("fmt"); | |
| 146 | + expect(requests.preview.specOverrides).toEqual(scopeOverride); | |
| 147 | + expect(requests.add.specFormat).toBe(requests.preview.specFormat); | |
| 148 | + expect(requests.add.specOverrides).toEqual(requests.preview.specOverrides); | |
| 149 | + expect(requests.add.displayDomain).toBe("figma.com"); | |
| 150 | + expect(requests.add.spec).toEqual({ kind: "url", url: "https://example.com/spec.yaml" }); | |
| 151 | + }); | |
| 152 | +}); | |
| 153 | + | |
| 154 | +describe("performQuickAdd", () => { | |
| 155 | + // The OPERATION under test, with the two mutations captured — the guard | |
| 156 | + // the review demanded twice: helper tests kept passing while a call site | |
| 157 | + // could quietly stop using the shared plan. | |
| 158 | + it("sends the identical spec plan to preview and add, and composed auth", async () => { | |
| 159 | + const previews: unknown[] = []; | |
| 160 | + const adds: unknown[] = []; | |
| 161 | + const deps: QuickAddDeps = { | |
| 162 | + presets: [ | |
| 163 | + { | |
| 164 | + id: "figma", | |
| 165 | + name: "Figma", | |
| 166 | + summary: "", | |
| 167 | + url: "https://example.com/spec.yaml", | |
| 168 | + specFormat: "fmt", | |
| 169 | + specOverrides: [...scopeOverride], | |
| 170 | + }, | |
| 171 | + ], | |
| 172 | + preview: (payload) => { | |
| 173 | + previews.push(payload); | |
| 174 | + return Promise.resolve( | |
| 175 | + Exit.succeed({ | |
| 176 | + headerPresets: [], | |
| 177 | + oauth2Presets: [ | |
| 178 | + { | |
| 179 | + label: "OAuth2", | |
| 180 | + securitySchemeName: "OAuth2", | |
| 181 | + flow: "authorizationCode" as const, | |
| 182 | + authorizationUrl: Option.some("https://example.com/authorize"), | |
| 183 | + tokenUrl: "https://example.com/token", | |
| 184 | + resource: Option.none(), | |
| 185 | + refreshUrl: Option.none(), | |
| 186 | + scopes: { "files:read": "" }, | |
| 187 | + identityScopes: [] as const, | |
| 188 | + }, | |
| 189 | + ], | |
| 190 | + servers: [{ url: "https://api.example.com" }], | |
| 191 | + }), | |
| 192 | + ); | |
| 193 | + }, | |
| 194 | + add: (payload) => { | |
| 195 | + adds.push(payload); | |
| 196 | + return Promise.resolve(Exit.succeed({ slug: "figma_api" })); | |
| 197 | + }, | |
| 198 | + }; | |
| 199 | + const result = await performQuickAdd(deps, { | |
| 200 | + url: "https://example.com/spec.yaml", | |
| 201 | + name: "Figma API", | |
| 202 | + slug: "figma-api", | |
| 203 | + domain: "figma.com", | |
| 204 | + authHeader: "Authorization: Bearer {token}", | |
| 205 | + }); | |
| 206 | + expect(result).toEqual({ ok: true, slug: "figma_api" }); | |
| 207 | + expect(previews).toHaveLength(1); | |
| 208 | + expect(adds).toHaveLength(1); | |
| 209 | + const preview = previews[0] as Record<string, unknown>; | |
| 210 | + const add = adds[0] as Record<string, unknown>; | |
| 211 | + // The regression contract: BOTH real payloads carry the one spec plan. | |
| 212 | + expect(preview.specFormat).toBe("fmt"); | |
| 213 | + expect(preview.specOverrides).toEqual(scopeOverride); | |
| 214 | + expect(add.specFormat).toBe(preview.specFormat); | |
| 215 | + expect(add.specOverrides).toEqual(preview.specOverrides); | |
| 216 | + expect(add.slug).toBe("figma_api"); | |
| 217 | + expect(add.displayDomain).toBe("figma.com"); | |
| 218 | + // Auth derived from the overridden document, registry key appended. | |
| 219 | + const template = add.authenticationTemplate as readonly unknown[]; | |
| 220 | + expect(JSON.stringify(template)).toContain("files:read"); | |
| 221 | + expect(JSON.stringify(template)).toContain("Authorization"); | |
| 222 | + }); | |
| 223 | + | |
| 224 | + it("skips the preview entirely when the registry declared no header", async () => { | |
| 225 | + const previews: unknown[] = []; | |
| 226 | + const deps: QuickAddDeps = { | |
| 227 | + presets: [], | |
| 228 | + preview: (payload) => { | |
| 229 | + previews.push(payload); | |
| 230 | + return Promise.resolve(Exit.fail("unexpected")); | |
| 231 | + }, | |
| 232 | + add: () => Promise.resolve(Exit.succeed({ slug: "plain" })), | |
| 233 | + }; | |
| 234 | + const result = await performQuickAdd(deps, { | |
| 235 | + url: "https://example.com/openapi.json", | |
| 236 | + name: "Plain API", | |
| 237 | + }); | |
| 238 | + expect(result).toEqual({ ok: true, slug: "plain" }); | |
| 239 | + expect(previews).toHaveLength(0); | |
| 240 | + }); | |
| 241 | +}); | |
| @@ -1,6 +1,234 @@ | ||
| 1 | −import { lazy } from "react"; | |
| 2 | −import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client"; | |
| 1 | +import { lazy, useCallback } from "react"; | |
| 2 | +import { useAtomSet } from "@effect/atom-react"; | |
| 3 | +import * as Exit from "effect/Exit"; | |
| 4 | +import type { | |
| 5 | + IntegrationPlugin, | |
| 6 | + IntegrationPreset, | |
| 7 | + IntegrationQuickAddInput, | |
| 8 | + IntegrationQuickAddResult, | |
| 9 | +} from "@executor-js/sdk/client"; | |
| 10 | +import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | |
| 11 | +import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; | |
| 12 | +import { placementFromHeaderPattern, type Placement } from "@executor-js/react/lib/auth-placements"; | |
| 13 | +import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | |
| 3 | 14 | import { openApiPresets } from "../sdk/presets"; |
| 15 | +import { detectedAuthenticationTemplates } from "../sdk/derive-auth"; | |
| 16 | +import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; | |
| 17 | +import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config"; | |
| 18 | +import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides"; | |
| 19 | + | |
| 20 | +const normalizedSpecUrl = (url: string): string => { | |
| 21 | + if (!URL.canParse(url)) return url.trim().replace(/\/$/, ""); | |
| 22 | + const parsed = new URL(url); | |
| 23 | + parsed.hash = ""; | |
| 24 | + parsed.searchParams.sort(); | |
| 25 | + return parsed.toString().replace(/\/$/, ""); | |
| 26 | +}; | |
| 27 | + | |
| 28 | +/** The preset table still knows things no spec can say — GitHub's OAuth | |
| 29 | + * endpoints against a spec that declares NO security at all. A registry row | |
| 30 | + * whose URL is a preset's URL gets that knowledge pulled across. Matches the | |
| 31 | + * plugin's COMPLETE preset list, so a deployment's custom presets | |
| 32 | + * contribute too, not just the built-ins. */ | |
| 33 | +const presetForSpecUrl = ( | |
| 34 | + presets: readonly IntegrationPreset[], | |
| 35 | + url: string, | |
| 36 | +): IntegrationPreset | undefined => { | |
| 37 | + const target = normalizedSpecUrl(url); | |
| 38 | + return presets.find( | |
| 39 | + (preset) => preset.url !== undefined && normalizedSpecUrl(preset.url) === target, | |
| 40 | + ); | |
| 41 | +}; | |
| 42 | + | |
| 43 | +/** One-click add for a registry OpenAPI row. The spec itself is the | |
| 44 | + * configuration: omitting `authenticationTemplate` and `baseUrl` tells the | |
| 45 | + * server to derive both from the document, exactly what the add page's | |
| 46 | + * untouched defaults submit. Registry spec overrides ride along. */ | |
| 47 | + | |
| 48 | +/** The spec inputs a quick add must hold CONSISTENT between preview and add: | |
| 49 | + * deriving auth from a document other than the one being added is how a | |
| 50 | + * preset's scope overrides got silently dropped. Preset overrides win over | |
| 51 | + * the registry's, mirroring the full add page. */ | |
| 52 | +export const quickAddSpecPlan = ( | |
| 53 | + preset: IntegrationPreset | undefined, | |
| 54 | + registryOverrides: ReturnType<typeof decodeOpenApiSpecOverrides>, | |
| 55 | +): { | |
| 56 | + readonly specFormat?: string; | |
| 57 | + readonly specOverrides?: NonNullable<ReturnType<typeof decodeOpenApiSpecOverrides>>; | |
| 58 | +} => { | |
| 59 | + const presetOverrides = preset?.specOverrides | |
| 60 | + ? decodeOpenApiSpecOverrides(preset.specOverrides) | |
| 61 | + : undefined; | |
| 62 | + // PRESENCE-based, mirroring the full page's `presetOverrides ?? | |
| 63 | + // registryOverrides`: a preset declaring an explicitly EMPTY override list | |
| 64 | + // is a decision — suppress the registry's patches — not an absence. | |
| 65 | + const effective = presetOverrides !== undefined ? presetOverrides : registryOverrides; | |
| 66 | + return { | |
| 67 | + ...(preset?.specFormat ? { specFormat: preset.specFormat } : {}), | |
| 68 | + ...(effective && effective.length > 0 ? { specOverrides: effective } : {}), | |
| 69 | + }; | |
| 70 | +}; | |
| 71 | + | |
| 72 | +/** The preview and add requests built from ONE plan, so they cannot diverge: | |
| 73 | + * deriving auth from a different effective document than the one stored is | |
| 74 | + * the bug this module has now had twice. The add payload takes further | |
| 75 | + * fields (family, health check, auth template) AFTER this base. */ | |
| 76 | +export const quickAddRequestPayloads = ( | |
| 77 | + input: { readonly url: string; readonly name: string; readonly domain?: string }, | |
| 78 | + slug: string, | |
| 79 | + specPlan: ReturnType<typeof quickAddSpecPlan>, | |
| 80 | +): { | |
| 81 | + readonly preview: { readonly spec: string } & ReturnType<typeof quickAddSpecPlan>; | |
| 82 | + readonly add: { | |
| 83 | + readonly spec: { readonly kind: "url"; readonly url: string }; | |
| 84 | + readonly slug: string; | |
| 85 | + readonly name: string; | |
| 86 | + readonly displayDomain?: string; | |
| 87 | + } & ReturnType<typeof quickAddSpecPlan>; | |
| 88 | +} => ({ | |
| 89 | + preview: { spec: input.url, ...specPlan }, | |
| 90 | + add: { | |
| 91 | + spec: { kind: "url", url: input.url }, | |
| 92 | + slug, | |
| 93 | + name: input.name, | |
| 94 | + ...(input.domain ? { displayDomain: input.domain } : {}), | |
| 95 | + ...specPlan, | |
| 96 | + }, | |
| 97 | +}); | |
| 98 | + | |
| 99 | +/** The full add page's method policy as one pure step: preset OAuth wins | |
| 100 | + * outright; else every preview-detected method is preserved and the | |
| 101 | + * registry's key placement is appended only when the detected set has no | |
| 102 | + * key method (GitHub declares no security at all). `preview` MUST be the | |
| 103 | + * summary of the same effective document the add will store. */ | |
| 104 | +export const composeQuickAddAuth = ( | |
| 105 | + presetMethods: readonly ReturnType<typeof openApiWireAuthInput>[], | |
| 106 | + registryPlacement: Placement | null, | |
| 107 | + preview: { | |
| 108 | + readonly headerPresets: Parameters<typeof detectedAuthenticationTemplates>[0]; | |
| 109 | + readonly oauth2Presets: Parameters<typeof detectedAuthenticationTemplates>[1]; | |
| 110 | + readonly servers: readonly { readonly url: string }[]; | |
| 111 | + } | null, | |
| 112 | +): readonly ReturnType<typeof openApiWireAuthInput>[] => { | |
| 113 | + if (presetMethods.length > 0) { | |
| 114 | + return registryPlacement | |
| 115 | + ? [...presetMethods, openApiWireAuthInput(templateFromPlacements([registryPlacement]))] | |
| 116 | + : presetMethods; | |
| 117 | + } | |
| 118 | + if (!registryPlacement || preview === null) return presetMethods; | |
| 119 | + const detected = detectedAuthenticationTemplates( | |
| 120 | + preview.headerPresets, | |
| 121 | + preview.oauth2Presets, | |
| 122 | + preview.servers[0]?.url ?? "", | |
| 123 | + ); | |
| 124 | + const detectedHasApiKey = detected.some((template) => template.kind === "apikey"); | |
| 125 | + return [ | |
| 126 | + ...detected.map(openApiWireAuthInput), | |
| 127 | + ...(detectedHasApiKey | |
| 128 | + ? [] | |
| 129 | + : [openApiWireAuthInput(templateFromPlacements([registryPlacement]))]), | |
| 130 | + ]; | |
| 131 | +}; | |
| 132 | + | |
| 133 | +export interface QuickAddDeps { | |
| 134 | + readonly presets: readonly IntegrationPreset[]; | |
| 135 | + /** The two mutations, injected so the OPERATION is testable against its | |
| 136 | + * actual outgoing payloads — helper-level tests kept passing while a call | |
| 137 | + * site quietly stopped using the shared plan. */ | |
| 138 | + readonly preview: ( | |
| 139 | + payload: ReturnType<typeof quickAddRequestPayloads>["preview"], | |
| 140 | + ) => Promise<Exit.Exit<NonNullable<Parameters<typeof composeQuickAddAuth>[2]>, unknown>>; | |
| 141 | + readonly add: ( | |
| 142 | + payload: ReturnType<typeof quickAddRequestPayloads>["add"] & { | |
| 143 | + readonly family?: string; | |
| 144 | + readonly healthCheck?: IntegrationPreset["healthCheck"]; | |
| 145 | + readonly authenticationTemplate?: ReturnType<typeof openApiWireAuthInput>[]; | |
| 146 | + }, | |
| 147 | + ) => Promise<Exit.Exit<{ readonly slug: string }, unknown>>; | |
| 148 | +} | |
| 149 | + | |
| 150 | +/** The complete quick-add operation, pure of React: everything between the | |
| 151 | + * picker's click and the two API calls. */ | |
| 152 | +export const performQuickAdd = async ( | |
| 153 | + deps: QuickAddDeps, | |
| 154 | + input: IntegrationQuickAddInput, | |
| 155 | +): Promise<IntegrationQuickAddResult> => { | |
| 156 | + const { presets } = deps; | |
| 157 | + const slug = slugifyNamespace(input.slug ?? input.name); | |
| 158 | + if (!slug) return { ok: false, reason: "no derivable slug" }; | |
| 159 | + // Registry overrides arrive as untyped JSON; a malformed patch goes to | |
| 160 | + // the configuration screen (its editor renders the parse error) rather | |
| 161 | + // than being silently dropped from a "successful" quick add. | |
| 162 | + const specOverrides = input.specOverrides | |
| 163 | + ? decodeOpenApiSpecOverrides(input.specOverrides) | |
| 164 | + : undefined; | |
| 165 | + if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) { | |
| 166 | + return { ok: false, reason: "unparseable spec overrides" }; | |
| 167 | + } | |
| 168 | + const preset = presetForSpecUrl(presets, input.url); | |
| 169 | + const registryPlacement = input.authHeader ? placementFromHeaderPattern(input.authHeader) : null; | |
| 170 | + const presetMethods = (preset?.authTemplate ?? []).flatMap((template) => | |
| 171 | + template.kind === "oauth2" | |
| 172 | + ? [ | |
| 173 | + openApiWireAuthInput({ | |
| 174 | + ...template, | |
| 175 | + slug: AuthTemplateSlug.make(template.slug), | |
| 176 | + resource: template.resource ?? undefined, | |
| 177 | + }), | |
| 178 | + ] | |
| 179 | + : [], | |
| 180 | + ); | |
| 181 | + // ONE spec plan for both preview and add: deriving auth from a | |
| 182 | + // different effective document than the one stored is how a preset's | |
| 183 | + // scope overrides got silently dropped. | |
| 184 | + const specPlan = quickAddSpecPlan(preset, specOverrides); | |
| 185 | + const requests = quickAddRequestPayloads( | |
| 186 | + { url: input.url, name: input.name, ...(input.domain ? { domain: input.domain } : {}) }, | |
| 187 | + slug, | |
| 188 | + specPlan, | |
| 189 | + ); | |
| 190 | + let preview: Parameters<typeof composeQuickAddAuth>[2] = null; | |
| 191 | + if (presetMethods.length === 0 && registryPlacement) { | |
| 192 | + // Composing with spec knowledge needs the spec: one preview call, | |
| 193 | + // only on this path (a plain registry row with no auth facts still | |
| 194 | + // adds with zero extra round trips). | |
| 195 | + const previewExit = await deps.preview(requests.preview); | |
| 196 | + if (Exit.isFailure(previewExit)) return { ok: false, reason: "preview failed" }; | |
| 197 | + preview = previewExit.value; | |
| 198 | + } | |
| 199 | + const authenticationTemplate = composeQuickAddAuth(presetMethods, registryPlacement, preview); | |
| 200 | + const exit = await deps.add({ | |
| 201 | + ...requests.add, | |
| 202 | + ...(preset?.family ? { family: preset.family } : {}), | |
| 203 | + ...(preset?.healthCheck ? { healthCheck: preset.healthCheck } : {}), | |
| 204 | + ...(authenticationTemplate.length > 0 | |
| 205 | + ? { authenticationTemplate: [...authenticationTemplate] } | |
| 206 | + : {}), | |
| 207 | + }); | |
| 208 | + if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" }; | |
| 209 | + return { ok: true, slug: String(exit.value.slug) }; | |
| 210 | +}; | |
| 211 | + | |
| 212 | +function makeUseQuickAdd(presets: readonly IntegrationPreset[]) { | |
| 213 | + return function useOpenApiQuickAdd(): ( | |
| 214 | + input: IntegrationQuickAddInput, | |
| 215 | + ) => Promise<IntegrationQuickAddResult> { | |
| 216 | + const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); | |
| 217 | + const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); | |
| 218 | + return useCallback( | |
| 219 | + (input) => | |
| 220 | + performQuickAdd( | |
| 221 | + { | |
| 222 | + presets, | |
| 223 | + preview: (payload) => doPreview({ payload }), | |
| 224 | + add: (payload) => doAdd({ payload, reactivityKeys: integrationWriteKeys }), | |
| 225 | + }, | |
| 226 | + input, | |
| 227 | + ), | |
| 228 | + [doAdd, doPreview], | |
| 229 | + ); | |
| 230 | + }; | |
| 231 | +} | |
| 4 | 232 | |
| 5 | 233 | const importAdd = () => import("./AddOpenApiIntegration"); |
| 6 | 234 | const importEditSheet = () => import("./UpdateSpecSection"); |
| @@ -10,20 +238,27 @@ export interface OpenApiClientConfig { · 3 unmodified lines | ||
| 10 | 238 | readonly presets?: readonly IntegrationPreset[]; |
| 11 | 239 | } |
| 12 | 240 | |
| 13 | −export const createOpenApiIntegrationPlugin = ( | |
| 14 | − config?: OpenApiClientConfig, | |
| 15 | −): IntegrationPlugin => ({ | |
| 16 | − key: "openapi", | |
| 17 | − label: "OpenAPI", | |
| 18 | − add: lazy(importAdd), | |
| 19 | − editSheet: lazy(importEditSheet), | |
| 20 | − accounts: lazy(importAccounts), | |
| 21 | − presets: [...openApiPresets, ...(config?.presets ?? [])], | |
| 22 | − preload: () => { | |
| 23 | − void importAdd(); | |
| 24 | − void importEditSheet(); | |
| 25 | − void importAccounts(); | |
| 26 | − }, | |
| 27 | −}); | |
| 241 | +export const createOpenApiIntegrationPlugin = (config?: OpenApiClientConfig): IntegrationPlugin => { | |
| 242 | + // Built-ins are registry-listed (the picker shows the registry's card); | |
| 243 | + // a deployment's custom presets are not, and keep their own cards. | |
| 244 | + const presets: readonly IntegrationPreset[] = [ | |
| 245 | + ...openApiPresets.map((preset) => ({ ...preset, registryListed: true })), | |
| 246 | + ...(config?.presets ?? []), | |
| 247 | + ]; | |
| 248 | + return { | |
| 249 | + key: "openapi", | |
| 250 | + label: "OpenAPI", | |
| 251 | + add: lazy(importAdd), | |
| 252 | + editSheet: lazy(importEditSheet), | |
| 253 | + accounts: lazy(importAccounts), | |
| 254 | + presets, | |
| 255 | + preload: () => { | |
| 256 | + void importAdd(); | |
| 257 | + void importEditSheet(); | |
| 258 | + void importAccounts(); | |
| 259 | + }, | |
| 260 | + useQuickAdd: makeUseQuickAdd(presets), | |
| 261 | + }; | |
| 262 | +}; | |
| 28 | 263 | |
| 29 | 264 | export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); |
| @@ -52,6 +52,11 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 51 unmodified lines | ||
| 52 | 52 | specUrl: Schema.optional(Schema.String), |
| 53 | 53 | /** Optional base URL override. */ |
| 54 | 54 | baseUrl: Schema.optional(Schema.String), |
| 55 | + /** The PRODUCT's domain, recorded at add time when the caller knew it (a | |
| 56 | + * registry row names notion.com). The spec's own host is often a code | |
| 57 | + * host (raw.githubusercontent.com) that says nothing about the product, | |
| 58 | + * which sent credential guidance and favicons to the wrong domain. */ | |
| 59 | + displayDomain: Schema.optional(Schema.String), | |
| 55 | 60 | /** Static headers applied to every request (no secret material). */ |
| 56 | 61 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 57 | 62 | /** Static query params applied to every request (no secret material). */ |
| @@ -71,3 +71,45 @@ describe("detectedAuthenticationTemplates", () => { · 70 unmodified lines | ||
| 71 | 71 | ).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]); |
| 72 | 72 | }); |
| 73 | 73 | }); |
| 74 | + | |
| 75 | +describe("query-located api keys", () => { | |
| 76 | + it("a query apiKey preset places the secret in the query string", () => { | |
| 77 | + const templates = detectedAuthenticationTemplates( | |
| 78 | + [ | |
| 79 | + { | |
| 80 | + label: "Legacy-API-key (query)", | |
| 81 | + headers: {}, | |
| 82 | + secretHeaders: [], | |
| 83 | + secretQueryParams: ["apiKey"], | |
| 84 | + }, | |
| 85 | + ], | |
| 86 | + [], | |
| 87 | + "https://example.com", | |
| 88 | + ); | |
| 89 | + expect(templates).toHaveLength(1); | |
| 90 | + const template = templates[0]; | |
| 91 | + expect(template?.kind).toBe("apikey"); | |
| 92 | + expect(template?.kind === "apikey" ? template.placements : []).toEqual([ | |
| 93 | + { carrier: "query", name: "apiKey" }, | |
| 94 | + ]); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + it("a strategy mixing a header and a query param asks for two inputs", () => { | |
| 98 | + const templates = detectedAuthenticationTemplates( | |
| 99 | + [ | |
| 100 | + { | |
| 101 | + label: "exp-api-key + apiKey (query)", | |
| 102 | + headers: { "exp-api-key": null }, | |
| 103 | + secretHeaders: ["exp-api-key"], | |
| 104 | + secretQueryParams: ["apiKey"], | |
| 105 | + }, | |
| 106 | + ], | |
| 107 | + [], | |
| 108 | + "https://example.com", | |
| 109 | + ); | |
| 110 | + const template = templates[0]; | |
| 111 | + const placements = template?.kind === "apikey" ? template.placements : []; | |
| 112 | + expect(placements.map((placement) => placement.carrier)).toEqual(["header", "query"]); | |
| 113 | + expect(new Set(placements.map((placement) => placement.variable)).size).toBe(2); | |
| 114 | + }); | |
| 115 | +}); | |
| @@ -104,20 +104,33 @@ const apiKeyTemplateFromHeaderPreset = ( · 103 unmodified lines | ||
| 104 | 104 | preset: HeaderPreset, |
| 105 | 105 | slug: AuthTemplateSlug, |
| 106 | 106 | ): APIKeyAuthentication => { |
| 107 | − const variables = variablesForHeaders(preset.secretHeaders); | |
| 107 | + const secretQueryParams = preset.secretQueryParams ?? []; | |
| 108 | + // One variable namespace across carriers, so a strategy sending two secrets | |
| 109 | + // (a header and a query param) asks for two distinct inputs. | |
| 110 | + const variables = variablesForHeaders([...preset.secretHeaders, ...secretQueryParams]); | |
| 108 | 111 | return { |
| 109 | 112 | slug, |
| 110 | 113 | kind: "apikey", |
| 111 | − placements: preset.secretHeaders.map((headerName) => { | |
| 112 | − const prefix = headerPrefix(preset, headerName); | |
| 113 | − const variable = variables.get(headerName); | |
| 114 | − return { | |
| 115 | − carrier: "header" as const, | |
| 116 | − name: headerName, | |
| 117 | − ...(prefix ? { prefix } : {}), | |
| 118 | − ...(variable ? { variable } : {}), | |
| 119 | − }; | |
| 120 | − }), | |
| 114 | + placements: [ | |
| 115 | + ...preset.secretHeaders.map((headerName) => { | |
| 116 | + const prefix = headerPrefix(preset, headerName); | |
| 117 | + const variable = variables.get(headerName); | |
| 118 | + return { | |
| 119 | + carrier: "header" as const, | |
| 120 | + name: headerName, | |
| 121 | + ...(prefix ? { prefix } : {}), | |
| 122 | + ...(variable ? { variable } : {}), | |
| 123 | + }; | |
| 124 | + }), | |
| 125 | + ...secretQueryParams.map((paramName) => { | |
| 126 | + const variable = variables.get(paramName); | |
| 127 | + return { | |
| 128 | + carrier: "query" as const, | |
| 129 | + name: paramName, | |
| 130 | + ...(variable ? { variable } : {}), | |
| 131 | + }; | |
| 132 | + }), | |
| 133 | + ], | |
| 121 | 134 | }; |
| 122 | 135 | }; |
| 123 | 136 | |
| @@ -98,6 +98,10 @@ export interface OpenApiSpecConfig { · 97 unmodified lines | ||
| 98 | 98 | * then the title). */ |
| 99 | 99 | readonly description?: string; |
| 100 | 100 | readonly baseUrl?: string; |
| 101 | + /** The product's domain, when the caller knows it (a registry row names | |
| 102 | + * notion.com); display identity for guidance/favicons when the spec's | |
| 103 | + * own host is a code host. */ | |
| 104 | + readonly displayDomain?: string; | |
| 101 | 105 | /** Static headers applied to every request (no secret material). */ |
| 102 | 106 | readonly headers?: Record<string, string>; |
| 103 | 107 | /** Static query params applied to every request. */ |
| @@ -307,6 +311,7 @@ const AddIntegrationInputSchema = Schema.Struct({ · 203 unmodified lines | ||
| 307 | 311 | name: Schema.optional(Schema.String), |
| 308 | 312 | description: Schema.optional(Schema.String), |
| 309 | 313 | baseUrl: Schema.optional(Schema.String), |
| 314 | + displayDomain: Schema.optional(Schema.String), | |
| 310 | 315 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 311 | 316 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 312 | 317 | specFormat: Schema.optional(Schema.String), |
| @@ -603,7 +608,9 @@ export const describeOpenApiIntegrationDisplay = ( · 290 unmodified lines | ||
| 603 | 608 | ): { readonly url?: string; readonly family?: string } => { |
| 604 | 609 | const config = decodeOpenApiIntegrationConfig(record.config); |
| 605 | 610 | return { |
| 606 | − url: config?.baseUrl ?? config?.specUrl, | |
| 611 | + url: | |
| 612 | + config?.baseUrl ?? | |
| 613 | + (config?.displayDomain ? `https://${config.displayDomain}` : config?.specUrl), | |
| 607 | 614 | ...(config?.family ? { family: config.family } : {}), |
| 608 | 615 | }; |
| 609 | 616 | }; |
| @@ -661,6 +668,7 @@ export const openApiPlugin = definePlugin< · 51 unmodified lines | ||
| 661 | 668 | const adapter = yield* resolveSpecFormatAdapter( |
| 662 | 669 | options?.specFormats ?? [], |
| 663 | 670 | config.specFormat, |
| 671 | + config.spec.kind === "url" ? config.spec.url : undefined, | |
| 664 | 672 | ); |
| 665 | 673 | if (adapter) { |
| 666 | 674 | if (config.spec.kind !== "url") { |
| @@ -782,6 +790,7 @@ export const openApiPlugin = definePlugin< · 115 unmodified lines | ||
| 782 | 790 | const adapter = yield* resolveSpecFormatAdapter( |
| 783 | 791 | options?.specFormats ?? [], |
| 784 | 792 | config.specFormat, |
| 793 | + config.spec.kind === "url" ? config.spec.url : undefined, | |
| 785 | 794 | ); |
| 786 | 795 | const derivedIdentity = |
| 787 | 796 | adapter?.deriveIdentity && resolved.document |
| @@ -865,6 +874,7 @@ export const openApiPlugin = definePlugin< · 77 unmodified lines | ||
| 865 | 874 | // resolved per call from the operation's `servers` (extracted from |
| 866 | 875 | // the spec), so we never bake a derived base URL into the config. |
| 867 | 876 | ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), |
| 877 | + ...(config.displayDomain ? { displayDomain: config.displayDomain } : {}), | |
| 868 | 878 | ...(config.headers ? { headers: config.headers } : {}), |
| 869 | 879 | ...(config.queryParams ? { queryParams: config.queryParams } : {}), |
| 870 | 880 | ...(config.specFormat ? { specFormat: config.specFormat } : {}), |
| @@ -145,7 +145,9 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 144 unmodified lines | ||
| 145 | 145 | id: "axiom", |
| 146 | 146 | name: "Axiom", |
| 147 | 147 | summary: "Log ingestion, querying, datasets, and monitors.", |
| 148 | − url: "https://axiom.co/docs/restapi/versions/v2.json", | |
| 148 | + // axiom.co/docs/restapi/versions/v2.json now 404s; the docs repo is where | |
| 149 | + // Axiom actually publishes the spec. | |
| 150 | + url: "https://raw.githubusercontent.com/axiomhq/docs/main/content/docs/%28api-reference%29/restapi/versions/v2.json", | |
| 149 | 151 | icon: "https://integrations.sh/logo/axiom.co", |
| 150 | 152 | }, |
| 151 | 153 | { |
| @@ -169,13 +171,6 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 17 unmodified lines | ||
| 169 | 171 | url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml", |
| 170 | 172 | icon: "https://integrations.sh/logo/digitalocean.com", |
| 171 | 173 | }, |
| 172 | − { | |
| 173 | − id: "petstore", | |
| 174 | − name: "Petstore", | |
| 175 | − summary: "Classic OpenAPI demo, no auth required.", | |
| 176 | − url: "https://petstore3.swagger.io/api/v3/openapi.json", | |
| 177 | − icon: "https://integrations.sh/logo/petstore3.swagger.io", | |
| 178 | − }, | |
| 179 | 174 | { |
| 180 | 175 | id: "val-town", |
| 181 | 176 | name: "Val Town", |
| @@ -122,6 +122,9 @@ export const HeaderPreset = Schema.Struct({ · 121 unmodified lines | ||
| 122 | 122 | headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)), |
| 123 | 123 | /** Which headers should be stored as secrets */ |
| 124 | 124 | secretHeaders: Schema.Array(Schema.String), |
| 125 | + /** Query parameters the strategy sends the secret in (apiKey in=query, | |
| 126 | + * e.g. Viator's legacy `?apiKey=`). Absent on older stored previews. */ | |
| 127 | + secretQueryParams: Schema.optional(Schema.Array(Schema.String)), | |
| 125 | 128 | }); |
| 126 | 129 | export type HeaderPreset = typeof HeaderPreset.Type; |
| 127 | 130 | |
| @@ -347,6 +350,7 @@ const buildHeaderPresets = ( · 219 unmodified lines | ||
| 347 | 350 | |
| 348 | 351 | const headers: Record<string, string | null> = {}; |
| 349 | 352 | const secretHeaders: string[] = []; |
| 353 | + const secretQueryParams: string[] = []; | |
| 350 | 354 | const labelParts: string[] = []; |
| 351 | 355 | |
| 352 | 356 | for (const scheme of resolved) { |
| @@ -363,8 +367,16 @@ const buildHeaderPresets = ( · 10 unmodified lines | ||
| 363 | 367 | headers[headerName] = null; |
| 364 | 368 | secretHeaders.push(headerName); |
| 365 | 369 | labelParts.push(scheme.name); |
| 370 | + } else if (scheme.type === "apiKey" && Option.getOrElse(scheme.in, () => "") === "query") { | |
| 371 | + secretQueryParams.push(Option.getOrElse(scheme.headerName, () => scheme.name)); | |
| 372 | + labelParts.push(`${scheme.name} (query)`); | |
| 366 | 373 | } else if (scheme.type === "apiKey") { |
| 367 | − labelParts.push(`${scheme.name} (${Option.getOrElse(scheme.in, () => "unknown")})`); | |
| 374 | + // Cookie (and unknown) locations are not renderable as a stored | |
| 375 | + // method — auth placements carry header|query — and a cookie scheme | |
| 376 | + // is usually the vendor console's own session, not a mintable | |
| 377 | + // credential. Contributing a label here used to produce a method | |
| 378 | + // with zero placements: an empty, unfillable card in the add flow. | |
| 379 | + continue; | |
| 368 | 380 | } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") { |
| 369 | 381 | return []; |
| 370 | 382 | } else { |
| @@ -372,21 +384,16 @@ const buildHeaderPresets = ( · 1 unmodified line | ||
| 372 | 384 | } |
| 373 | 385 | } |
| 374 | 386 | |
| 375 | − if (Object.keys(headers).length === 0 && resolved.length > 0) { | |
| 376 | − return [ | |
| 377 | − HeaderPreset.make({ | |
| 378 | − label: labelParts.join(" + "), | |
| 379 | − headers: {}, | |
| 380 | − secretHeaders: [], | |
| 381 | − }), | |
| 382 | − ]; | |
| 383 | − } | |
| 387 | + // A strategy in which nothing is renderable (cookie-only, or an exotic | |
| 388 | + // scheme type) yields no preset rather than an empty one. | |
| 389 | + if (secretHeaders.length === 0 && secretQueryParams.length === 0) return []; | |
| 384 | 390 | |
| 385 | 391 | return [ |
| 386 | 392 | HeaderPreset.make({ |
| 387 | 393 | label: labelParts.join(" + "), |
| 388 | 394 | headers, |
| 389 | 395 | secretHeaders, |
| 396 | + ...(secretQueryParams.length > 0 ? { secretQueryParams } : {}), | |
| 390 | 397 | }), |
| 391 | 398 | ]; |
| 392 | 399 | }); |
| @@ -41,13 +41,24 @@ export interface SpecFormatAdapter { · 40 unmodified lines | ||
| 41 | 41 | readonly id: string; |
| 42 | 42 | readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>; |
| 43 | 43 | readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null; |
| 44 | + /** Recognizes URLs this adapter can convert. Lets a hand-pasted URL take | |
| 45 | + * the same path a preset would: without this, a Google Discovery URL only | |
| 46 | + * worked through a preset carrying `specFormat`, and pasting the identical | |
| 47 | + * URL failed as "not OpenAPI". */ | |
| 48 | + readonly detectsUrl?: (url: string) => boolean; | |
| 44 | 49 | } |
| 45 | 50 | |
| 46 | 51 | export const resolveSpecFormatAdapter = ( |
| 47 | 52 | adapters: readonly SpecFormatAdapter[], |
| 48 | 53 | id: string | undefined, |
| 54 | + url?: string, | |
| 49 | 55 | ): Effect.Effect<SpecFormatAdapter | null, OpenApiParseError> => { |
| 50 | − if (!id) return Effect.succeed(null); | |
| 56 | + if (!id) { | |
| 57 | + if (!url) return Effect.succeed(null); | |
| 58 | + return Effect.succeed( | |
| 59 | + adapters.find((candidate) => candidate.detectsUrl?.(url) === true) ?? null, | |
| 60 | + ); | |
| 61 | + } | |
| 51 | 62 | const adapter = adapters.find((candidate) => candidate.id === id); |
| 52 | 63 | if (adapter) return Effect.succeed(adapter); |
| 53 | 64 | return Effect.fail(new OpenApiParseError({ message: `Unknown OpenAPI spec format: ${id}` })); |
| @@ -13,7 +13,7 @@ afterEach(() => { · 12 unmodified lines | ||
| 13 | 13 | |
| 14 | 14 | describe("analytics seam", () => { |
| 15 | 15 | it("is a no-op when no client is mounted", () => { |
| 16 | − expect(() => trackEvent("integration_connect_dialog_opened")).not.toThrow(); | |
| 16 | + expect(() => trackEvent("integration_browse_opened", { via: "header" })).not.toThrow(); | |
| 17 | 17 | }); |
| 18 | 18 | |
| 19 | 19 | it("forwards name and properties to the mounted client", () => { |
| @@ -31,12 +31,22 @@ type Owner = "org" | "user"; · 30 unmodified lines | ||
| 31 | 31 | |
| 32 | 32 | export interface AnalyticsEvents { |
| 33 | 33 | // ── Integrations ───────────────────────────────────────────────────────── |
| 34 | − integration_connect_dialog_opened: {}; | |
| 34 | + /** The full-page picker was opened. Replaces the connect dialog, whose | |
| 35 | + * `integration_connect_dialog_opened` this supersedes — keep both readable | |
| 36 | + * in dashboards spanning the change. */ | |
| 37 | + integration_browse_opened: { via: "header" | "empty-state" | "sidebar" }; | |
| 35 | 38 | integration_detect_submitted: { |
| 36 | 39 | success: boolean; |
| 37 | 40 | detected_kind?: string; |
| 38 | 41 | confidence?: string; |
| 39 | 42 | }; |
| 43 | + /** Thumbs on the AI-generated credential guidance panel — the accuracy | |
| 44 | + * signal for the registry's machine-written setup text. */ | |
| 45 | + credential_guidance_rated: { | |
| 46 | + domain: string; | |
| 47 | + credential_label: string; | |
| 48 | + vote: "up" | "down"; | |
| 49 | + }; | |
| 40 | 50 | integration_add_started: { |
| 41 | 51 | plugin_key: string; |
| 42 | 52 | via: "detect" | "manual" | "preset" | "command_palette" | "catalog"; |
| @@ -27,6 +27,7 @@ import { · 26 unmodified lines | ||
| 27 | 27 | checkConnectionHealth, |
| 28 | 28 | connectionsAllAtom, |
| 29 | 29 | createOAuthClientOptimistic, |
| 30 | + integrationAtom, | |
| 30 | 31 | integrationHealthCheckAtom, |
| 31 | 32 | integrationHealthCheckCandidatesAtom, |
| 32 | 33 | oauthClientsOptimisticAtom, |
| @@ -85,6 +86,7 @@ import { · 52 unmodified lines | ||
| 85 | 86 | } from "./oauth-client-form"; |
| 86 | 87 | import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog"; |
| 87 | 88 | import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal"; |
| 89 | +import { CredentialGuidancePanel } from "./credential-guidance"; | |
| 88 | 90 | import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; |
| 89 | 91 | import { connectionIdentifier } from "../lib/connection-name"; |
| 90 | 92 | import { Badge } from "./badge"; |
| @@ -625,6 +627,14 @@ export const connectionExistsMessage = (label: string): string => · 534 unmodified lines | ||
| 625 | 627 | * explicit choice. Personal: a connection is most often a personal credential. */ |
| 626 | 628 | export const DEFAULT_CONNECTION_OWNER: Owner = "user"; |
| 627 | 629 | |
| 630 | +/** The method the modal opens on. OAuth needs a registered app (or a DCR | |
| 631 | + * round-trip) before "Connect" does anything; a key is one paste. When an | |
| 632 | + * integration declares both, starting on OAuth greets most users with | |
| 633 | + * "Register app" — a dead end — while the working method sits one tab over. | |
| 634 | + * Prefer the first non-OAuth method; OAuth stays one click away. */ | |
| 635 | +export const preferredMethodId = (methods: readonly AuthMethod[]): string => | |
| 636 | + (methods.find((method) => method.kind !== "oauth") ?? methods[0])?.id ?? ""; | |
| 637 | + | |
| 628 | 638 | const authMethodKey = (method: AuthMethod): string => |
| 629 | 639 | method.source === "custom" ? `custom:${String(method.template)}` : `declared:${method.id}`; |
| 630 | 640 | |
| @@ -1423,7 +1433,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 792 unmodified lines | ||
| 1423 | 1433 | ); |
| 1424 | 1434 | const [addingMethod, setAddingMethod] = useState(false); |
| 1425 | 1435 | |
| 1426 | − const [methodId, setMethodId] = useState<string>(methods[0]?.id ?? ""); | |
| 1436 | + const [methodId, setMethodId] = useState<string>(preferredMethodId(methods)); | |
| 1427 | 1437 | // One value per distinct credential input (`variable → pasted value`). A |
| 1428 | 1438 | // single-secret method has just `{ token }`; a method with two distinct inputs |
| 1429 | 1439 | // (e.g. Datadog's two keys) collects one value per variable. |
| @@ -1501,6 +1511,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 71 unmodified lines | ||
| 1501 | 1511 | // The integration's declared health check + its candidate operations. When a |
| 1502 | 1512 | // check is configured we probe against it; when not, the user picks one of the |
| 1503 | 1513 | // candidates inline to test the key (and we save it). |
| 1514 | + // The integration's display URL is how the registry's credential guidance is | |
| 1515 | + // located — it names the provider this key belongs to. | |
| 1516 | + const integrationRecord = useAtomValue(integrationAtom(integration)); | |
| 1517 | + const integrationDisplayUrl = AsyncResult.isSuccess(integrationRecord) | |
| 1518 | + ? integrationRecord.value?.displayUrl | |
| 1519 | + : undefined; | |
| 1504 | 1520 | const healthCheckResult = useAtomValue(integrationHealthCheckAtom(integration)); |
| 1505 | 1521 | const configuredHealthCheck = AsyncResult.isSuccess(healthCheckResult) |
| 1506 | 1522 | ? healthCheckResult.value |
| @@ -1605,7 +1621,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 98 unmodified lines | ||
| 1605 | 1621 | m.id === initialState.template || String(m.template) === initialState.template, |
| 1606 | 1622 | ) |
| 1607 | 1623 | : undefined; |
| 1608 | − setMethodId(initialMethod?.id ?? allMethods[0]!.id); | |
| 1624 | + setMethodId(initialMethod?.id ?? preferredMethodId(allMethods)); | |
| 1609 | 1625 | }, [allMethods, initialState?.template, methodId]); |
| 1610 | 1626 | |
| 1611 | 1627 | // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link. |
| @@ -2894,6 +2910,15 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1282 unmodified lines | ||
| 2894 | 2910 | <div className="space-y-2"> |
| 2895 | 2911 | <StepHeader index={1} label={authStepLabel} /> |
| 2896 | 2912 | |
| 2913 | + {/* What this key is called at the provider, the page | |
| 2914 | + that mints it, and their own setup steps. The | |
| 2915 | + question this dialog used to ask without | |
| 2916 | + answering. */} | |
| 2917 | + <CredentialGuidancePanel | |
| 2918 | + displayUrl={integrationDisplayUrl} | |
| 2919 | + methodKind={method?.kind} | |
| 2920 | + /> | |
| 2921 | + | |
| 2897 | 2922 | {isOAuth && method ? ( |
| 2898 | 2923 | cimdActive ? ( |
| 2899 | 2924 | <div className="space-y-2 rounded-lg border border-ring/40 bg-accent/30 px-3 py-3"> |
| @@ -142,10 +142,11 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) { · 141 unmodified lines | ||
| 142 | 142 | <div className="flex items-center justify-between"> |
| 143 | 143 | <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> |
| 144 | 144 | {detected ? <LockIcon className="size-3 shrink-0" aria-hidden /> : null} |
| 145 | − <span> | |
| 146 | − Method {index + 1} | |
| 147 | − {row.seedLabel ? ` · ${row.seedLabel}` : ""} | |
| 148 | − </span> | |
| 145 | + {/* Named by what it is. "Method 1" told the reader nothing | |
| 146 | + except that there might be a Method 2, and next to a | |
| 147 | + masked value it read as a locked credential field — | |
| 148 | + people tried to paste their key into it. */} | |
| 149 | + <span>{detected ? detectedMethodTitle(row) : `Method ${index + 1}`}</span> | |
| 149 | 150 | </span> |
| 150 | 151 | <Button |
| 151 | 152 | type="button" |
| @@ -181,6 +182,14 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) { · 29 unmodified lines | ||
| 181 | 182 | ); |
| 182 | 183 | } |
| 183 | 184 | |
| 185 | +/** What a spec-detected method should be called: the credential it wants, plus | |
| 186 | + * the spec's own name for it when there is more than one to tell apart. */ | |
| 187 | +function detectedMethodTitle(row: AuthMethodRow): string { | |
| 188 | + const kind = row.value.kind; | |
| 189 | + const base = kind === "oauth" ? "OAuth" : kind === "apikey" ? "API key" : "No authentication"; | |
| 190 | + return row.seedLabel ? `${base} · ${row.seedLabel}` : base; | |
| 191 | +} | |
| 192 | + | |
| 184 | 193 | /** One read-only `label value` line, mono value, for the detected summary. */ |
| 185 | 194 | function SpecField(props: { readonly label: string; readonly value: string }) { |
| 186 | 195 | return ( |
| @@ -204,16 +213,11 @@ function DetectedMethodSummary(props: { · 17 unmodified lines | ||
| 204 | 213 | const { value, oauthMetadata } = props; |
| 205 | 214 | // Name the auth kind explicitly: a detection label like MCP's "Detected" |
| 206 | 215 | // doesn't say whether it's OAuth or an API key, so surface it here. |
| 207 | − const kindLabel = | |
| 208 | − value.kind === "oauth" ? "OAuth" : value.kind === "apikey" ? "API key" : "No auth"; | |
| 209 | 216 | return ( |
| 210 | 217 | <div className="space-y-2"> |
| 211 | − <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground"> | |
| 212 | − {kindLabel} | |
| 213 | − </p> | |
| 214 | 218 | <div |
| 215 | 219 | aria-disabled |
| 216 | − className="cursor-not-allowed select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground" | |
| 220 | + className="select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground" | |
| 217 | 221 | > |
| 218 | 222 | {value.kind === "none" && ( |
| 219 | 223 | <p className="text-xs">No credential — tools are callable without an account.</p> |
| @@ -221,10 +225,13 @@ function DetectedMethodSummary(props: { · 1 unmodified line | ||
| 221 | 225 | |
| 222 | 226 | {value.kind === "apikey" && |
| 223 | 227 | (value.placements.length > 0 ? ( |
| 224 | − <div className="flex flex-wrap gap-x-4 gap-y-1"> | |
| 225 | − {value.placements.map((placement, i: number) => ( | |
| 226 | − <PlacementLine key={i} placement={placement} /> | |
| 227 | − ))} | |
| 228 | + <div className="space-y-1"> | |
| 229 | + <p className="text-xs">This API takes a key, sent as:</p> | |
| 230 | + <div className="flex flex-wrap gap-x-4 gap-y-1"> | |
| 231 | + {value.placements.map((placement, i: number) => ( | |
| 232 | + <PlacementLine key={i} placement={placement} /> | |
| 233 | + ))} | |
| 234 | + </div> | |
| 228 | 235 | </div> |
| 229 | 236 | ) : null)} |
| 230 | 237 | |
| @@ -246,7 +253,18 @@ function DetectedMethodSummary(props: { · 15 unmodified lines | ||
| 246 | 253 | ))} |
| 247 | 254 | </div> |
| 248 | 255 | |
| 249 | − <p className="text-[11px] text-muted-foreground">Pulled from spec. Remove to override.</p> | |
| 256 | + {/* The question this screen used to leave open. Nothing on the add form | |
| 257 | + takes a credential; the key is entered once the integration exists, | |
| 258 | + from its own page. Saying so is the difference between a confident | |
| 259 | + next click and hunting for a field that is not here. */} | |
| 260 | + <p className="text-[11px] text-muted-foreground"> | |
| 261 | + {/* "Declared by this API", not "read from the spec": GraphQL surfaces | |
| 262 | + have no spec — their methods arrive from the registry record — and | |
| 263 | + the claim that matters is the same either way. */} | |
| 264 | + {value.kind === "oauth" | |
| 265 | + ? "Declared by this API. You'll sign in after adding this integration." | |
| 266 | + : "Declared by this API. You'll enter your key after adding this integration."} | |
| 267 | + </p> | |
| 250 | 268 | </div> |
| 251 | 269 | ); |
| 252 | 270 | } |
| @@ -0,0 +1,168 @@ | ||
| 1 | +import { useMemo, useState } from "react"; | |
| 2 | +import { ArrowUpRightIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react"; | |
| 3 | +import { getDomain } from "tldts"; | |
| 4 | + | |
| 5 | +import { trackEvent } from "../api/analytics"; | |
| 6 | +import { useCredentialGuidance } from "../lib/integrations-sh-catalog"; | |
| 7 | +import { cn } from "../lib/utils"; | |
| 8 | + | |
| 9 | +// --------------------------------------------------------------------------- | |
| 10 | +// What this key is, and where to get one. | |
| 11 | +// | |
| 12 | +// The registry records, per provider, what the credential is called, the page | |
| 13 | +// that mints it, and the provider's own setup steps. Asking someone for a key | |
| 14 | +// without any of that is the reason the connect form produced "Okay so what | |
| 15 | +// type of key do I need?" — the answer existed, one fetch away, and was thrown | |
| 16 | +// out on arrival. | |
| 17 | +// | |
| 18 | +// Strictly supplementary: the form works unchanged when the registry knows | |
| 19 | +// nothing, and this renders nothing at all rather than an empty frame. | |
| 20 | +// --------------------------------------------------------------------------- | |
| 21 | + | |
| 22 | +/** The registry's setup text is markdown. Links, bold and inline code are all | |
| 23 | + * that occur in practice, so render those three rather than pull a markdown | |
| 24 | + * pipeline into a form. */ | |
| 25 | +function SetupText(props: { readonly text: string }) { | |
| 26 | + const nodes = useMemo( | |
| 27 | + () => | |
| 28 | + props.text | |
| 29 | + .split(/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`)/g) | |
| 30 | + .filter((chunk) => chunk.length > 0) | |
| 31 | + .map((chunk, index) => { | |
| 32 | + const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(chunk); | |
| 33 | + if (link) { | |
| 34 | + return ( | |
| 35 | + <a | |
| 36 | + key={index} | |
| 37 | + href={link[2]} | |
| 38 | + target="_blank" | |
| 39 | + rel="noreferrer" | |
| 40 | + className="font-medium text-foreground underline underline-offset-2" | |
| 41 | + > | |
| 42 | + {link[1]} | |
| 43 | + </a> | |
| 44 | + ); | |
| 45 | + } | |
| 46 | + const bold = /^\*\*([^*]+)\*\*$/.exec(chunk); | |
| 47 | + if (bold) { | |
| 48 | + return ( | |
| 49 | + <span key={index} className="font-medium text-foreground"> | |
| 50 | + {bold[1]} | |
| 51 | + </span> | |
| 52 | + ); | |
| 53 | + } | |
| 54 | + const code = /^`([^`]+)`$/.exec(chunk); | |
| 55 | + if (code) { | |
| 56 | + return ( | |
| 57 | + <code key={index} className="font-mono text-[0.9em] text-foreground"> | |
| 58 | + {code[1]} | |
| 59 | + </code> | |
| 60 | + ); | |
| 61 | + } | |
| 62 | + return <span key={index}>{chunk}</span>; | |
| 63 | + }), | |
| 64 | + [props.text], | |
| 65 | + ); | |
| 66 | + return <p className="text-xs leading-relaxed text-muted-foreground">{nodes}</p>; | |
| 67 | +} | |
| 68 | + | |
| 69 | +export function CredentialGuidancePanel(props: { | |
| 70 | + /** The integration's display URL; the domain is read from it. */ | |
| 71 | + readonly displayUrl: string | undefined; | |
| 72 | + /** "oauth" hides key guidance — there is no key to fetch. */ | |
| 73 | + readonly methodKind: string | undefined; | |
| 74 | +}) { | |
| 75 | + const domain = useMemo( | |
| 76 | + () => (props.displayUrl ? getDomain(props.displayUrl) : null), | |
| 77 | + [props.displayUrl], | |
| 78 | + ); | |
| 79 | + const guidance = useCredentialGuidance(domain); | |
| 80 | + | |
| 81 | + const relevant = useMemo(() => { | |
| 82 | + if (props.methodKind === "oauth") { | |
| 83 | + return guidance.find((entry) => entry.type.startsWith("oauth")); | |
| 84 | + } | |
| 85 | + // Anything that is not an OAuth grant is the key the form is asking for. | |
| 86 | + return guidance.find((entry) => !entry.type.startsWith("oauth")) ?? guidance[0]; | |
| 87 | + }, [guidance, props.methodKind]); | |
| 88 | + | |
| 89 | + if (!relevant || (!relevant.generateUrl && !relevant.setup)) return null; | |
| 90 | + | |
| 91 | + return ( | |
| 92 | + <div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 px-3 py-2.5"> | |
| 93 | + <div className="flex items-baseline justify-between gap-3"> | |
| 94 | + <span className="text-xs font-medium text-foreground">{relevant.label}</span> | |
| 95 | + {relevant.generateUrl ? ( | |
| 96 | + <a | |
| 97 | + href={relevant.generateUrl} | |
| 98 | + target="_blank" | |
| 99 | + rel="noreferrer" | |
| 100 | + className="flex shrink-0 items-center gap-1 text-xs font-medium text-foreground underline underline-offset-2" | |
| 101 | + > | |
| 102 | + Get a key | |
| 103 | + <ArrowUpRightIcon className="size-3" aria-hidden /> | |
| 104 | + </a> | |
| 105 | + ) : null} | |
| 106 | + </div> | |
| 107 | + {relevant.setup ? <SetupText text={relevant.setup} /> : null} | |
| 108 | + {/* Provenance, stated plainly: this text is machine-written registry | |
| 109 | + data, not the provider's own docs — the reader should weight it | |
| 110 | + accordingly (menus move, prefixes change). The thumbs are the | |
| 111 | + accuracy loop: votes land in analytics keyed by domain, so wrong | |
| 112 | + guidance is findable instead of silently misleading. */} | |
| 113 | + <div className="flex items-center justify-between gap-3 pt-0.5"> | |
| 114 | + <p className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground/60"> | |
| 115 | + AI-generated guidance via{" "} | |
| 116 | + <a | |
| 117 | + href={`https://integrations.sh/${domain ?? ""}`} | |
| 118 | + target="_blank" | |
| 119 | + rel="noreferrer" | |
| 120 | + className="underline underline-offset-2 hover:text-muted-foreground" | |
| 121 | + > | |
| 122 | + integrations.sh | |
| 123 | + </a> | |
| 124 | + </p> | |
| 125 | + <GuidanceVote domain={domain ?? ""} label={relevant.label} /> | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | + ); | |
| 129 | +} | |
| 130 | + | |
| 131 | +/** One vote per render of the panel; the pair collapses to the chosen thumb | |
| 132 | + * so a second opinion means reopening, not flip-flopping the metric. */ | |
| 133 | +function GuidanceVote(props: { readonly domain: string; readonly label: string }) { | |
| 134 | + const [voted, setVoted] = useState<"up" | "down" | null>(null); | |
| 135 | + const vote = (value: "up" | "down") => { | |
| 136 | + if (voted) return; | |
| 137 | + setVoted(value); | |
| 138 | + trackEvent("credential_guidance_rated", { | |
| 139 | + domain: props.domain, | |
| 140 | + credential_label: props.label, | |
| 141 | + vote: value, | |
| 142 | + }); | |
| 143 | + }; | |
| 144 | + return ( | |
| 145 | + <span className="flex shrink-0 items-center gap-1"> | |
| 146 | + {(["up", "down"] as const).map((value) => { | |
| 147 | + const Icon = value === "up" ? ThumbsUpIcon : ThumbsDownIcon; | |
| 148 | + if (voted && voted !== value) return null; | |
| 149 | + return ( | |
| 150 | + // oxlint-disable-next-line react/forbid-elements -- an icon chip, not a Button variant | |
| 151 | + <button | |
| 152 | + key={value} | |
| 153 | + type="button" | |
| 154 | + aria-label={value === "up" ? "Guidance was accurate" : "Guidance was wrong"} | |
| 155 | + disabled={voted !== null} | |
| 156 | + onClick={() => vote(value)} | |
| 157 | + className={cn( | |
| 158 | + "rounded p-1 text-muted-foreground/50 transition-colors", | |
| 159 | + voted === value ? "text-foreground" : "hover:bg-muted hover:text-muted-foreground", | |
| 160 | + )} | |
| 161 | + > | |
| 162 | + <Icon className="size-3" aria-hidden /> | |
| 163 | + </button> | |
| 164 | + ); | |
| 165 | + })} | |
| 166 | + </span> | |
| 167 | + ); | |
| 168 | +} | |
| @@ -209,7 +209,6 @@ export function IntegrationFavicon({ · 208 unmodified lines | ||
| 209 | 209 | alt="" |
| 210 | 210 | width={size} |
| 211 | 211 | height={size} |
| 212 | − loading="lazy" | |
| 213 | 212 | onError={() => |
| 214 | 213 | setFailedSrcs((current) => |
| 215 | 214 | current.includes(failedCandidate) ? current : [...current, failedCandidate], |
| @@ -37,6 +37,7 @@ export const CONSOLE_ROUTE_PATHS = [ · 36 unmodified lines | ||
| 37 | 37 | "/connect/$integrationSlug", |
| 38 | 38 | "/integrations/$namespace", |
| 39 | 39 | "/integrations/add/$pluginKey", |
| 40 | + "/integrations/browse", | |
| 40 | 41 | "/policies", |
| 41 | 42 | "/secrets", |
| 42 | 43 | "/tools", |
| @@ -78,6 +79,7 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 35 unmodified lines | ||
| 78 | 79 | "/integrations/add/$pluginKey", |
| 79 | 80 | route("/integrations/add/$pluginKey", file("integrations.add.$pluginKey.tsx")), |
| 80 | 81 | ], |
| 82 | + ["/integrations/browse", route("/integrations/browse", file("integrations.browse.tsx"))], | |
| 81 | 83 | ["/policies", route("/policies", file("policies.tsx"))], |
| 82 | 84 | ["/secrets", route("/secrets", file("secrets.tsx"))], |
| 83 | 85 | ["/tools", route("/tools", file("tools.tsx"))], |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import type { AuthMethodDescriptor } from "@executor-js/sdk/shared"; |
| 3 | 3 | |
| 4 | −import { authMethodsFromDescriptors } from "./auth-placements"; | |
| 4 | +import { authMethodsFromDescriptors, placementFromHeaderPattern } from "./auth-placements"; | |
| 5 | 5 | |
| 6 | 6 | describe("authMethodsFromDescriptors", () => { |
| 7 | 7 | it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => { |
| @@ -101,3 +101,25 @@ describe("authMethodsFromDescriptors", () => { · 93 unmodified lines | ||
| 101 | 101 | expect(authMethodsFromDescriptors([])).toEqual([]); |
| 102 | 102 | }); |
| 103 | 103 | }); |
| 104 | + | |
| 105 | +describe("placementFromHeaderPattern", () => { | |
| 106 | + it("keeps a Bearer prefix, space included", () => { | |
| 107 | + expect(placementFromHeaderPattern("Authorization: Bearer {token}")).toEqual({ | |
| 108 | + carrier: "header", | |
| 109 | + name: "Authorization", | |
| 110 | + prefix: "Bearer ", | |
| 111 | + }); | |
| 112 | + }); | |
| 113 | + | |
| 114 | + it("an empty prefix is meaningful: Linear's keys take no Bearer", () => { | |
| 115 | + expect(placementFromHeaderPattern("Authorization: {api_key}")).toEqual({ | |
| 116 | + carrier: "header", | |
| 117 | + name: "Authorization", | |
| 118 | + prefix: "", | |
| 119 | + }); | |
| 120 | + }); | |
| 121 | + | |
| 122 | + it("rejects text that is not a header pattern", () => { | |
| 123 | + expect(placementFromHeaderPattern("just some prose")).toBeNull(); | |
| 124 | + }); | |
| 125 | +}); | |
| @@ -40,6 +40,22 @@ export interface Placement { · 39 unmodified lines | ||
| 40 | 40 | /** A fresh, empty header placement — the default first row in an editor. */ |
| 41 | 41 | export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", prefix: "" }); |
| 42 | 42 | |
| 43 | +/** Parses a registry header pattern — `"Authorization: Bearer {token}"` — into | |
| 44 | + * a placement. The literal text between the colon and the `{variable}` is the | |
| 45 | + * prefix, and its ABSENCE is load-bearing: `"Authorization: {api_key}"` is how | |
| 46 | + * the registry says Linear's personal keys take no Bearer prefix. */ | |
| 47 | +export function placementFromHeaderPattern(pattern: string): Placement | null { | |
| 48 | + const match = /^([A-Za-z0-9-]+):\s*(.*)$/.exec(pattern.trim()); | |
| 49 | + if (!match) return null; | |
| 50 | + const rest = match[2] ?? ""; | |
| 51 | + const brace = rest.indexOf("{"); | |
| 52 | + return { | |
| 53 | + carrier: "header", | |
| 54 | + name: match[1] ?? "", | |
| 55 | + prefix: brace >= 0 ? rest.slice(0, brace) : "", | |
| 56 | + }; | |
| 57 | +} | |
| 58 | + | |
| 43 | 59 | /** What an auth method is, presentationally. `kind` drives the credential UI: |
| 44 | 60 | * `oauth` shows a Connect button, `none` creates a connection with no |
| 45 | 61 | * credential inputs, and apikey/custom fill secrets across `placements`. |
| @@ -9,7 +9,7 @@ · 8 unmodified lines | ||
| 9 | 9 | // GraphQL endpoint). |
| 10 | 10 | // --------------------------------------------------------------------------- |
| 11 | 11 | |
| 12 | −import { useEffect, useRef, useState } from "react"; | |
| 12 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 13 | 13 | import * as Data from "effect/Data"; |
| 14 | 14 | import * as Effect from "effect/Effect"; |
| 15 | 15 | import * as Exit from "effect/Exit"; |
| @@ -28,10 +28,49 @@ export type CatalogKind = (typeof CONNECTABLE_KINDS)[number]; · 12 unmodified lines | ||
| 28 | 28 | const isConnectableKind = (kind: string): kind is CatalogKind => |
| 29 | 29 | (CONNECTABLE_KINDS as readonly string[]).includes(kind); |
| 30 | 30 | |
| 31 | +/** One connectable surface of a domain, as the registry reports it. | |
| 32 | + * | |
| 33 | + * `slug` is the registry's stable identifier for THIS surface, and it is the | |
| 34 | + * namespace the add flow seeds — which makes it the honest answer to "have I | |
| 35 | + * added this already?". The domain is not: a vendor's surfaces can live on | |
| 36 | + * other hosts entirely (GitHub's MCP server is on api.githubcopilot.com), and | |
| 37 | + * one domain routinely offers several surfaces that are separate | |
| 38 | + * integrations. */ | |
| 39 | +export interface CatalogSurface { | |
| 40 | + readonly kind: CatalogKind; | |
| 41 | + readonly slug: string; | |
| 42 | + /** What to point the add flow at. Absent when the registry has no | |
| 43 | + * machine-readable locator, in which case the surface document still has to | |
| 44 | + * be fetched on click. */ | |
| 45 | + readonly url?: string; | |
| 46 | + /** A hand-picked product mark (Google Calendar's own logo rather than the | |
| 47 | + * generic G), present only on curated surfaces. */ | |
| 48 | + readonly icon?: string; | |
| 49 | + /** How to authenticate, for surfaces whose connect target cannot describe | |
| 50 | + * it itself — a GraphQL endpoint has no spec document, so the registry is | |
| 51 | + * the only carrier of facts like Linear's no-Bearer-prefix header. */ | |
| 52 | + readonly auth?: { | |
| 53 | + readonly kind?: string; | |
| 54 | + /** Header pattern, e.g. "Authorization: Bearer {token}". */ | |
| 55 | + readonly header?: string; | |
| 56 | + readonly note?: string; | |
| 57 | + }; | |
| 58 | + /** RFC 6902 JSON Patch the registry says to apply to the fetched spec — | |
| 59 | + * how a vendor's published document gets improved without hosting a fork | |
| 60 | + * (e.g. Neon's console session cookies posing as security schemes). */ | |
| 61 | + readonly specOverrides?: readonly unknown[]; | |
| 62 | +} | |
| 63 | + | |
| 31 | 64 | export interface CatalogSearchEntry { |
| 32 | 65 | readonly domain: string; |
| 66 | + /** The product's display name, when the row is a named product rather than | |
| 67 | + * a bare domain — "Outlook Mail" on graph.microsoft.com. */ | |
| 68 | + readonly name?: string; | |
| 33 | 69 | readonly description: string; |
| 34 | 70 | readonly kinds: readonly CatalogKind[]; |
| 71 | + /** Present on registries new enough to report it; absent responses fall back | |
| 72 | + * to resolving the surface document per click. */ | |
| 73 | + readonly surfaces?: readonly CatalogSurface[]; | |
| 35 | 74 | } |
| 36 | 75 | |
| 37 | 76 | export const catalogLogoUrl = (domain: string, size: number): string => |
| @@ -44,7 +83,14 @@ class CatalogRequestError extends Data.TaggedError("CatalogRequestError")<{ · 6 unmodified lines | ||
| 44 | 83 | const fetchCatalogJson = (url: URL): Effect.Effect<unknown, CatalogRequestError> => |
| 45 | 84 | Effect.gen(function* () { |
| 46 | 85 | const response = yield* Effect.tryPromise({ |
| 47 | − try: () => fetch(url), | |
| 86 | + // ALWAYS REVALIDATE. The catalog is a live service and its corrections | |
| 87 | + // must reach the console immediately, but the browser will honour | |
| 88 | + // whatever max-age the response carried when it was stored — a stale | |
| 89 | + // entry with a long TTL kept showing a domain that had been merged away | |
| 90 | + // and an MCP endpoint that does not exist, hours after the fix shipped. | |
| 91 | + // `no-cache` still sends the conditional request, so a 304 costs nothing | |
| 92 | + // when nothing has changed. | |
| 93 | + try: () => fetch(url, { cache: "no-cache" }), | |
| 48 | 94 | catch: () => new CatalogRequestError({ message: `Failed to reach ${url.host}.` }), |
| 49 | 95 | }); |
| 50 | 96 | if (!response.ok) { |
| @@ -66,34 +112,103 @@ const SearchResponse = Schema.Struct({ · 15 unmodified lines | ||
| 66 | 112 | results: Schema.Array( |
| 67 | 113 | Schema.Struct({ |
| 68 | 114 | domain: Schema.String, |
| 115 | + name: Schema.optional(Schema.String), | |
| 69 | 116 | description: Schema.String, |
| 70 | 117 | kinds: Schema.Array(Schema.String), |
| 118 | + surfaces: Schema.optional( | |
| 119 | + Schema.Array( | |
| 120 | + Schema.Struct({ | |
| 121 | + kind: Schema.String, | |
| 122 | + slug: Schema.String, | |
| 123 | + url: Schema.optional(Schema.String), | |
| 124 | + icon: Schema.optional(Schema.String), | |
| 125 | + auth: Schema.optional( | |
| 126 | + Schema.Struct({ | |
| 127 | + kind: Schema.optional(Schema.String), | |
| 128 | + header: Schema.optional(Schema.String), | |
| 129 | + note: Schema.optional(Schema.String), | |
| 130 | + }), | |
| 131 | + ), | |
| 132 | + specOverrides: Schema.optional(Schema.Array(Schema.Unknown)), | |
| 133 | + }), | |
| 134 | + ), | |
| 135 | + ), | |
| 71 | 136 | }), |
| 72 | 137 | ), |
| 73 | 138 | }); |
| 74 | 139 | const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse); |
| 75 | 140 | |
| 141 | +export interface CatalogSearchPage { | |
| 142 | + readonly entries: readonly CatalogSearchEntry[]; | |
| 143 | + /** Rows the registry returned BEFORE the connectable-kind filter. Paging | |
| 144 | + * exhaustion must key on this: a page can be full at the registry and | |
| 145 | + * shrink here (CLI-only rows are dropped), and judging "no more pages" by | |
| 146 | + * the filtered length ended the infinite scroll mid-catalog. */ | |
| 147 | + readonly rawCount: number; | |
| 148 | +} | |
| 149 | + | |
| 150 | +export const parseCatalogSearchPage = (payload: unknown): CatalogSearchPage => ({ | |
| 151 | + entries: parseCatalogSearch(payload), | |
| 152 | + rawCount: Option.match(decodeSearchResponse(payload), { | |
| 153 | + onNone: () => 0, | |
| 154 | + onSome: ({ results }) => results.length, | |
| 155 | + }), | |
| 156 | +}); | |
| 157 | + | |
| 76 | 158 | export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntry[] => |
| 77 | 159 | Option.match(decodeSearchResponse(payload), { |
| 78 | 160 | onNone: () => [], |
| 79 | 161 | onSome: ({ results }) => |
| 80 | 162 | results |
| 81 | − .map((entry) => ({ | |
| 82 | − domain: entry.domain, | |
| 83 | − description: entry.description, | |
| 84 | − kinds: entry.kinds.filter(isConnectableKind), | |
| 85 | − })) | |
| 163 | + .map((entry) => { | |
| 164 | + const surfaces = (entry.surfaces ?? []).flatMap((surface): readonly CatalogSurface[] => | |
| 165 | + isConnectableKind(surface.kind) | |
| 166 | + ? [ | |
| 167 | + { | |
| 168 | + kind: surface.kind, | |
| 169 | + slug: surface.slug, | |
| 170 | + ...(surface.url ? { url: surface.url } : {}), | |
| 171 | + ...(surface.icon ? { icon: surface.icon } : {}), | |
| 172 | + ...(surface.auth ? { auth: surface.auth } : {}), | |
| 173 | + ...(surface.specOverrides && surface.specOverrides.length > 0 | |
| 174 | + ? { specOverrides: surface.specOverrides } | |
| 175 | + : {}), | |
| 176 | + }, | |
| 177 | + ] | |
| 178 | + : [], | |
| 179 | + ); | |
| 180 | + return { | |
| 181 | + domain: entry.domain, | |
| 182 | + ...(entry.name && entry.name !== entry.domain ? { name: entry.name } : {}), | |
| 183 | + description: entry.description, | |
| 184 | + kinds: entry.kinds.filter(isConnectableKind), | |
| 185 | + ...(surfaces.length > 0 ? { surfaces } : {}), | |
| 186 | + }; | |
| 187 | + }) | |
| 86 | 188 | .filter((entry) => entry.kinds.length > 0), |
| 87 | 189 | }); |
| 88 | 190 | |
| 191 | +export interface CatalogQuery { | |
| 192 | + /** Free text. EMPTY IS MEANINGFUL: the registry answers an empty query with | |
| 193 | + * its popularity-ordered head, which is what a browse view wants. */ | |
| 194 | + readonly query: string; | |
| 195 | + /** Restrict to one surface kind. Omitted means every connectable kind. */ | |
| 196 | + readonly kind?: CatalogKind; | |
| 197 | + /** The registry caps this at 100. */ | |
| 198 | + readonly limit?: number; | |
| 199 | + /** Ranked results to skip — how the picker pages an endless list. */ | |
| 200 | + readonly offset?: number; | |
| 201 | +} | |
| 202 | + | |
| 89 | 203 | export const searchCatalog = ( |
| 90 | − query: string, | |
| 91 | − limit = 10, | |
| 92 | −): Effect.Effect<readonly CatalogSearchEntry[], CatalogRequestError> => { | |
| 204 | + input: CatalogQuery, | |
| 205 | +): Effect.Effect<CatalogSearchPage, CatalogRequestError> => { | |
| 93 | 206 | const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN); |
| 94 | − url.searchParams.set("q", query); | |
| 95 | − url.searchParams.set("limit", String(limit)); | |
| 96 | − return Effect.map(fetchCatalogJson(url), parseCatalogSearch); | |
| 207 | + url.searchParams.set("q", input.query); | |
| 208 | + url.searchParams.set("limit", String(input.limit ?? 10)); | |
| 209 | + if (input.kind) url.searchParams.set("kind", input.kind); | |
| 210 | + if (input.offset) url.searchParams.set("offset", String(input.offset)); | |
| 211 | + return Effect.map(fetchCatalogJson(url), parseCatalogSearchPage); | |
| 97 | 212 | }; |
| 98 | 213 | |
| 99 | 214 | // --------------------------------------------------------------------------- |
| @@ -135,18 +250,35 @@ const connectUrlOf = (surface: SurfaceRecord, kind: CatalogKind): string | undef · 35 unmodified lines | ||
| 135 | 250 | ? surface.url |
| 136 | 251 | : undefined; |
| 137 | 252 | |
| 253 | +const normalizedProductToken = (value: string): string => | |
| 254 | + value.toLowerCase().replace(/[^a-z0-9]/g, ""); | |
| 255 | + | |
| 138 | 256 | export const pickConnectTarget = ( |
| 139 | 257 | payload: unknown, |
| 140 | 258 | kind: CatalogKind, |
| 259 | + preferredName?: string, | |
| 141 | 260 | ): CatalogConnectTarget | undefined => |
| 142 | 261 | Option.match(decodeSurfaceDocument(payload), { |
| 143 | 262 | onNone: () => undefined, |
| 144 | 263 | onSome: ({ surfaces }) => { |
| 145 | − for (const surface of surfaces) { | |
| 264 | + // One domain can carry several same-kind products (Google Photos | |
| 265 | + // Library and Picker share photos.google.com). When the caller names | |
| 266 | + // the product, a slug whose normalized form matches wins; first-of-kind | |
| 267 | + // is only the answer when nothing identifies the product. | |
| 268 | + const usable = surfaces.flatMap((surface) => { | |
| 146 | 269 | const url = connectUrlOf(surface, kind); |
| 147 | − if (url) return { kind, url, ...(surface.slug ? { slug: surface.slug } : {}) }; | |
| 270 | + return url | |
| 271 | + ? [{ kind, url, ...(surface.slug ? { slug: surface.slug } : {}) } as CatalogConnectTarget] | |
| 272 | + : []; | |
| 273 | + }); | |
| 274 | + if (preferredName) { | |
| 275 | + const token = normalizedProductToken(preferredName); | |
| 276 | + const named = usable.find( | |
| 277 | + (target) => target.slug && normalizedProductToken(target.slug) === token, | |
| 278 | + ); | |
| 279 | + if (named) return named; | |
| 148 | 280 | } |
| 149 | − return undefined; | |
| 281 | + return usable[0]; | |
| 150 | 282 | }, |
| 151 | 283 | }); |
| 152 | 284 | |
| @@ -155,11 +287,12 @@ export const pickConnectTarget = ( · 2 unmodified lines | ||
| 155 | 287 | export const resolveConnectTarget = ( |
| 156 | 288 | domain: string, |
| 157 | 289 | kinds: readonly CatalogKind[], |
| 290 | + preferredName?: string, | |
| 158 | 291 | ): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => { |
| 159 | 292 | const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN); |
| 160 | 293 | return Effect.map(fetchCatalogJson(url), (payload) => { |
| 161 | 294 | for (const kind of kinds) { |
| 162 | − const target = pickConnectTarget(payload, kind); | |
| 295 | + const target = pickConnectTarget(payload, kind, preferredName); | |
| 163 | 296 | if (target) return target; |
| 164 | 297 | } |
| 165 | 298 | return undefined; |
| @@ -172,16 +305,27 @@ export const resolveConnectTarget = ( · 6 unmodified lines | ||
| 172 | 305 | |
| 173 | 306 | /** Domains already represented by a loaded plugin's presets, so the catalog |
| 174 | 307 | * section doesn't repeat what the preset list above it already shows. */ |
| 308 | +/** The domain a preset represents, read from its integrations.sh logo URL | |
| 309 | + * first (which names the domain outright) and its spec/endpoint URL second. */ | |
| 310 | +export const presetDomain = (preset: { | |
| 311 | + readonly icon?: string; | |
| 312 | + readonly url?: string; | |
| 313 | +}): string | null => { | |
| 314 | + for (const candidate of [preset.icon, preset.url]) { | |
| 315 | + if (!candidate) continue; | |
| 316 | + const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate); | |
| 317 | + const domain = logoMatch?.[1] ?? getDomain(candidate); | |
| 318 | + if (domain) return domain; | |
| 319 | + } | |
| 320 | + return null; | |
| 321 | +}; | |
| 322 | + | |
| 175 | 323 | export const presetDomains = (plugins: readonly IntegrationPlugin[]): ReadonlySet<string> => { |
| 176 | 324 | const domains = new Set<string>(); |
| 177 | 325 | for (const plugin of plugins) { |
| 178 | 326 | for (const preset of plugin.presets ?? []) { |
| 179 | − for (const candidate of [preset.icon, preset.url]) { | |
| 180 | − if (!candidate) continue; | |
| 181 | − const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate); | |
| 182 | − const domain = logoMatch?.[1] ?? getDomain(candidate); | |
| 183 | − if (domain) domains.add(domain); | |
| 184 | − } | |
| 327 | + const domain = presetDomain(preset); | |
| 328 | + if (domain) domains.add(domain); | |
| 185 | 329 | } |
| 186 | 330 | } |
| 187 | 331 | return domains; |
| @@ -206,6 +350,9 @@ export const filterCatalogEntries = ( · 18 unmodified lines | ||
| 206 | 350 | .map((entry) => ({ |
| 207 | 351 | ...entry, |
| 208 | 352 | kinds: entry.kinds.filter((kind) => opts.availableKinds.includes(kind)), |
| 353 | + ...(entry.surfaces | |
| 354 | + ? { surfaces: entry.surfaces.filter((s) => opts.availableKinds.includes(s.kind)) } | |
| 355 | + : {}), | |
| 209 | 356 | })) |
| 210 | 357 | .filter((entry) => entry.kinds.length > 0); |
| 211 | 358 | |
| @@ -213,43 +360,293 @@ export const filterCatalogEntries = ( · 1 unmodified line | ||
| 213 | 360 | // Hook — debounced search with an in-session response cache |
| 214 | 361 | // --------------------------------------------------------------------------- |
| 215 | 362 | |
| 216 | −const SEARCH_DEBOUNCE_MS = 250; | |
| 363 | +const SEARCH_DEBOUNCE_MS = 150; | |
| 364 | +/** Free-text queries wait for a second character; a browse (empty query) does | |
| 365 | + * not, because there is nothing to narrow. */ | |
| 217 | 366 | const MIN_QUERY_LENGTH = 2; |
| 218 | −const searchCache = new Map<string, readonly CatalogSearchEntry[]>(); | |
| 367 | +const BROWSE_LIMIT = 60; | |
| 219 | 368 | |
| 220 | 369 | export interface CatalogSearchState { |
| 221 | 370 | readonly entries: readonly CatalogSearchEntry[]; |
| 222 | 371 | readonly loading: boolean; |
| 372 | + /** The entries belong to a previous query, held over while the current one | |
| 373 | + * loads. Callers must not render them as if they answered the live query. */ | |
| 374 | + readonly stale: boolean; | |
| 375 | + /** The registry could not be reached. The rest of the page still works, so | |
| 376 | + * this is a section-level notice rather than a page error. */ | |
| 377 | + readonly failed: boolean; | |
| 378 | + /** The last page came back full, so more rows likely exist. */ | |
| 379 | + readonly hasMore: boolean; | |
| 380 | + /** A further page is being fetched and appended. */ | |
| 381 | + readonly loadingMore: boolean; | |
| 382 | + /** Fetch the next page. Safe to call repeatedly: no-op while a page is in | |
| 383 | + * flight or once the catalog is exhausted. */ | |
| 384 | + readonly loadMore: () => void; | |
| 223 | 385 | } |
| 224 | 386 | |
| 225 | −export function useCatalogSearch(rawQuery: string): CatalogSearchState { | |
| 226 | − const query = rawQuery.trim().toLowerCase(); | |
| 227 | − const [state, setState] = useState<CatalogSearchState>({ entries: [], loading: false }); | |
| 387 | +const cacheKey = (input: CatalogQuery): string => | |
| 388 | + `${input.kind ?? "all"}:${input.limit ?? 0}:${input.query}`; | |
| 389 | +/** Responses are cached for as long as the registry says they are fresh, and | |
| 390 | + * no longer. The first version of this cache had no expiry at all, so a | |
| 391 | + * session that searched once kept that answer for its whole life — a registry | |
| 392 | + * correction could ship and the console would keep showing the old catalog | |
| 393 | + * until the tab was closed. */ | |
| 394 | +const SEARCH_TTL_MS = 60_000; | |
| 395 | +const searchCache = new Map< | |
| 396 | + string, | |
| 397 | + { readonly at: number; readonly entries: readonly CatalogSearchEntry[] } | |
| 398 | +>(); | |
| 399 | + | |
| 400 | +/** | |
| 401 | + * Browse or search the public registry. | |
| 402 | + * | |
| 403 | + * One hook for both because they are the same request with a different `q`: | |
| 404 | + * an empty query returns the popularity-ordered head, a non-empty one filters | |
| 405 | + * it. Search stays server-side and edge-cached, so no view ever downloads the | |
| 406 | + * multi-thousand-entry catalog. | |
| 407 | + */ | |
| 408 | +export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState { | |
| 409 | + const query = input.query.trim().toLowerCase(); | |
| 410 | + const kind = input.kind; | |
| 411 | + const limit = input.limit ?? BROWSE_LIMIT; | |
| 412 | + const requestKeyOf = (forQuery: string) => | |
| 413 | + cacheKey({ query: forQuery, limit, ...(kind ? { kind } : {}) }); | |
| 414 | + const requestKey = requestKeyOf(query); | |
| 415 | + const [state, setState] = useState<{ | |
| 416 | + readonly entries: readonly CatalogSearchEntry[]; | |
| 417 | + /** The request the entries belong to. Held-over results from a previous | |
| 418 | + * query are DELIBERATE (no empty flash between keystrokes), but they | |
| 419 | + * must be knowable as stale — rendering them unmarked is how a | |
| 420 | + * "calendar" search showed Gmail cards. */ | |
| 421 | + readonly forKey: string; | |
| 422 | + readonly loading: boolean; | |
| 423 | + readonly failed: boolean; | |
| 424 | + }>({ entries: [], forKey: requestKey, loading: true, failed: false }); | |
| 228 | 425 | const generation = useRef(0); |
| 426 | + // Pages beyond the first, keyed to the request they extend so a keystroke | |
| 427 | + // discards them with the first page rather than leaking into new results. | |
| 428 | + const [more, setMore] = useState<{ | |
| 429 | + readonly key: string; | |
| 430 | + readonly entries: readonly CatalogSearchEntry[]; | |
| 431 | + /** RAW rows consumed by extra pages — the server pages by raw index, and | |
| 432 | + * the connectable-kind filter makes filtered counts useless as offsets. */ | |
| 433 | + readonly rawConsumed: number; | |
| 434 | + readonly loadingMore: boolean; | |
| 435 | + readonly exhausted: boolean; | |
| 436 | + }>({ key: requestKey, entries: [], rawConsumed: 0, loadingMore: false, exhausted: false }); | |
| 437 | + | |
| 438 | + useEffect(() => { | |
| 439 | + setMore({ key: requestKey, entries: [], rawConsumed: 0, loadingMore: false, exhausted: false }); | |
| 440 | + // requestKey is derived from (query, kind, limit) — same trigger set. | |
| 441 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 442 | + }, [query, kind, limit]); | |
| 229 | 443 | |
| 230 | 444 | useEffect(() => { |
| 231 | 445 | const requestId = ++generation.current; |
| 232 | − if (query.length < MIN_QUERY_LENGTH) { | |
| 233 | − setState({ entries: [], loading: false }); | |
| 446 | + // A partial word is not yet a query; leave the previous results in place | |
| 447 | + // rather than flashing an empty list between keystrokes. | |
| 448 | + if (query.length > 0 && query.length < MIN_QUERY_LENGTH) { | |
| 449 | + // Held entries keep their own forKey, so the hook reports them stale. | |
| 450 | + setState((previous) => ({ ...previous, loading: false })); | |
| 234 | 451 | return; |
| 235 | 452 | } |
| 236 | − const cached = searchCache.get(query); | |
| 237 | − if (cached) { | |
| 238 | − setState({ entries: cached, loading: false }); | |
| 453 | + const request: CatalogQuery = { query, limit, ...(kind ? { kind } : {}) }; | |
| 454 | + const cached = searchCache.get(cacheKey(request)); | |
| 455 | + if (cached && Date.now() - cached.at < SEARCH_TTL_MS) { | |
| 456 | + setState({ | |
| 457 | + entries: cached.entries, | |
| 458 | + forKey: cacheKey(request), | |
| 459 | + loading: false, | |
| 460 | + failed: false, | |
| 461 | + }); | |
| 239 | 462 | return; |
| 240 | 463 | } |
| 241 | 464 | setState((previous) => ({ ...previous, loading: true })); |
| 242 | − const timer = setTimeout(() => { | |
| 243 | − void Effect.runPromiseExit(searchCatalog(query)).then((exit) => { | |
| 244 | − if (Exit.isSuccess(exit)) searchCache.set(query, exit.value); | |
| 245 | − if (generation.current !== requestId) return; | |
| 246 | − // Reachability is best-effort — the presets and URL detection above | |
| 247 | − // the catalog section keep working without it. | |
| 248 | − setState({ entries: Exit.isSuccess(exit) ? exit.value : [], loading: false }); | |
| 249 | − }); | |
| 250 | − }, SEARCH_DEBOUNCE_MS); | |
| 465 | + const timer = setTimeout( | |
| 466 | + () => { | |
| 467 | + void Effect.runPromiseExit(searchCatalog(request)).then((exit) => { | |
| 468 | + if (Exit.isSuccess(exit)) { | |
| 469 | + searchCache.set(cacheKey(request), { at: Date.now(), entries: exit.value.entries }); | |
| 470 | + } | |
| 471 | + if (generation.current !== requestId) return; | |
| 472 | + setState({ | |
| 473 | + entries: Exit.isSuccess(exit) ? exit.value.entries : [], | |
| 474 | + forKey: cacheKey(request), | |
| 475 | + loading: false, | |
| 476 | + failed: Exit.isFailure(exit), | |
| 477 | + }); | |
| 478 | + }); | |
| 479 | + }, | |
| 480 | + // The first browse should not sit behind a debounce meant for typing. | |
| 481 | + query.length === 0 ? 0 : SEARCH_DEBOUNCE_MS, | |
| 482 | + ); | |
| 251 | 483 | return () => clearTimeout(timer); |
| 252 | − }, [query]); | |
| 484 | + }, [query, kind, limit]); | |
| 485 | + | |
| 486 | + const extras = more.key === requestKey ? more : null; | |
| 487 | + const baseCount = state.entries.length; | |
| 488 | + const hasMore = | |
| 489 | + !state.loading && | |
| 490 | + !state.failed && | |
| 491 | + baseCount >= limit && | |
| 492 | + !(extras?.exhausted ?? false) && | |
| 493 | + !(query.length > 0 && query.length < MIN_QUERY_LENGTH); | |
| 494 | + | |
| 495 | + // SYNCHRONOUS in-flight guard. A fast scroll fires the sentinel several | |
| 496 | + // times before React commits `loadingMore`, and every call then saw the | |
| 497 | + // stale false, fetched the SAME offset in parallel, appended the duplicate | |
| 498 | + // page, and pushed the next offset past rows that were never shown. A ref | |
| 499 | + // is settled the instant the first call claims it. | |
| 500 | + const pageInFlight = useRef(false); | |
| 501 | + | |
| 502 | + const loadMore = useCallback(() => { | |
| 503 | + if (!hasMore || pageInFlight.current) return; | |
| 504 | + pageInFlight.current = true; | |
| 505 | + setMore((previous) => | |
| 506 | + previous.key === requestKey | |
| 507 | + ? { ...previous, loadingMore: true } | |
| 508 | + : { key: requestKey, entries: [], rawConsumed: 0, loadingMore: true, exhausted: false }, | |
| 509 | + ); | |
| 510 | + // The first page consumed `limit` raw rows (were it short, hasMore would | |
| 511 | + // already be false); extra pages report their raw size. | |
| 512 | + const request: CatalogQuery = { | |
| 513 | + query, | |
| 514 | + limit, | |
| 515 | + offset: limit + (extras?.rawConsumed ?? 0), | |
| 516 | + ...(kind ? { kind } : {}), | |
| 517 | + }; | |
| 518 | + void Effect.runPromiseExit(searchCatalog(request)).then((exit) => { | |
| 519 | + pageInFlight.current = false; | |
| 520 | + setMore((previous) => { | |
| 521 | + if (previous.key !== requestKey) return previous; | |
| 522 | + const page = Exit.isSuccess(exit) ? exit.value : { entries: [], rawCount: 0 }; | |
| 523 | + return { | |
| 524 | + key: requestKey, | |
| 525 | + entries: [...previous.entries, ...page.entries], | |
| 526 | + rawConsumed: previous.rawConsumed + page.rawCount, | |
| 527 | + loadingMore: false, | |
| 528 | + // Exhaustion keys on the RAW page size: the connectable-kind filter | |
| 529 | + // shrinks pages, and a failed fetch is not the end of the catalog — | |
| 530 | + // the sentinel simply tries again next time it scrolls into view. | |
| 531 | + exhausted: Exit.isSuccess(exit) && page.rawCount < limit, | |
| 532 | + }; | |
| 533 | + }); | |
| 534 | + }); | |
| 535 | + }, [hasMore, requestKey, query, kind, limit, extras?.rawConsumed]); | |
| 536 | + | |
| 537 | + const entries = useMemo(() => { | |
| 538 | + if (!extras || extras.entries.length === 0) return state.entries; | |
| 539 | + // Ranking can shift between page fetches (the live index moves); a row | |
| 540 | + // that slid across a page boundary must not render twice. | |
| 541 | + const seen = new Set(state.entries.map((entry) => `${entry.domain}|${entry.name ?? ""}`)); | |
| 542 | + return [ | |
| 543 | + ...state.entries, | |
| 544 | + ...extras.entries.filter((entry) => { | |
| 545 | + const key = `${entry.domain}|${entry.name ?? ""}`; | |
| 546 | + if (seen.has(key)) return false; | |
| 547 | + seen.add(key); | |
| 548 | + return true; | |
| 549 | + }), | |
| 550 | + ]; | |
| 551 | + }, [state.entries, extras]); | |
| 552 | + | |
| 553 | + return { | |
| 554 | + entries, | |
| 555 | + loading: state.loading, | |
| 556 | + failed: state.failed, | |
| 557 | + /** The rendered entries belong to a PREVIOUS query (held over while the | |
| 558 | + * current one loads, or while the input is one character). Callers | |
| 559 | + * decide the treatment; unmarked stale results read as wrong results. */ | |
| 560 | + stale: state.forKey !== requestKey, | |
| 561 | + hasMore: hasMore && state.forKey === requestKey, | |
| 562 | + loadingMore: extras?.loadingMore ?? false, | |
| 563 | + loadMore, | |
| 564 | + }; | |
| 565 | +} | |
| 566 | + | |
| 567 | +// --------------------------------------------------------------------------- | |
| 568 | +// Credential guidance | |
| 569 | +// | |
| 570 | +// The per-domain surface document records, for each way of authenticating, | |
| 571 | +// what the credential is called at the provider, the page that mints it, and | |
| 572 | +// the provider's own setup steps. The console fetched this document for its | |
| 573 | +// connect URL and discarded the rest — which is why the add-connection modal | |
| 574 | +// could not answer "what kind of key do I need?" or "where do I get one?" | |
| 575 | +// while asking for exactly that. | |
| 576 | +// --------------------------------------------------------------------------- | |
| 577 | + | |
| 578 | +export interface CredentialGuidance { | |
| 579 | + readonly id: string; | |
| 580 | + readonly type: string; | |
| 581 | + /** What the provider calls this credential ("Personal API key"). */ | |
| 582 | + readonly label: string; | |
| 583 | + /** The page that mints it. */ | |
| 584 | + readonly generateUrl?: string; | |
| 585 | + /** The provider's own instructions, as markdown. */ | |
| 586 | + readonly setup?: string; | |
| 587 | +} | |
| 588 | + | |
| 589 | +const CredentialsDocument = Schema.Struct({ | |
| 590 | + credentials: Schema.optional( | |
| 591 | + Schema.Record( | |
| 592 | + Schema.String, | |
| 593 | + Schema.Struct({ | |
| 594 | + type: Schema.optional(Schema.String), | |
| 595 | + label: Schema.optional(Schema.String), | |
| 596 | + generateUrl: Schema.optional(Schema.String), | |
| 597 | + setup: Schema.optional(Schema.String), | |
| 598 | + }), | |
| 599 | + ), | |
| 600 | + ), | |
| 601 | +}); | |
| 602 | +const decodeCredentials = Schema.decodeUnknownOption(CredentialsDocument); | |
| 603 | + | |
| 604 | +export const fetchCredentialGuidance = ( | |
| 605 | + domain: string, | |
| 606 | +): Effect.Effect<readonly CredentialGuidance[], CatalogRequestError> => { | |
| 607 | + const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN); | |
| 608 | + return Effect.map(fetchCatalogJson(url), (payload) => | |
| 609 | + Option.match(decodeCredentials(payload), { | |
| 610 | + onNone: () => [], | |
| 611 | + onSome: ({ credentials }) => | |
| 612 | + Object.entries(credentials ?? {}).map(([id, value]) => ({ | |
| 613 | + id, | |
| 614 | + type: value.type ?? "unknown", | |
| 615 | + label: value.label ?? id, | |
| 616 | + ...(value.generateUrl ? { generateUrl: value.generateUrl } : {}), | |
| 617 | + ...(value.setup ? { setup: value.setup } : {}), | |
| 618 | + })), | |
| 619 | + }), | |
| 620 | + ); | |
| 621 | +}; | |
| 622 | + | |
| 623 | +const guidanceCache = new Map<string, readonly CredentialGuidance[]>(); | |
| 624 | + | |
| 625 | +/** Guidance for a domain, or an empty list when the registry has none. Never | |
| 626 | + * fails loudly: this is help text, and a connection form must work without it. */ | |
| 627 | +export function useCredentialGuidance(domain: string | null): readonly CredentialGuidance[] { | |
| 628 | + const [state, setState] = useState<readonly CredentialGuidance[]>([]); | |
| 629 | + | |
| 630 | + useEffect(() => { | |
| 631 | + if (!domain) { | |
| 632 | + setState([]); | |
| 633 | + return; | |
| 634 | + } | |
| 635 | + const cached = guidanceCache.get(domain); | |
| 636 | + if (cached) { | |
| 637 | + setState(cached); | |
| 638 | + return; | |
| 639 | + } | |
| 640 | + let live = true; | |
| 641 | + void Effect.runPromiseExit(fetchCredentialGuidance(domain)).then((exit) => { | |
| 642 | + const value = Exit.isSuccess(exit) ? exit.value : []; | |
| 643 | + guidanceCache.set(domain, value); | |
| 644 | + if (live) setState(value); | |
| 645 | + }); | |
| 646 | + return () => { | |
| 647 | + live = false; | |
| 648 | + }; | |
| 649 | + }, [domain]); | |
| 253 | 650 | |
| 254 | 651 | return state; |
| 255 | 652 | } |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { Link, Outlet, useLocation, useParams } from "@tanstack/react-router"; | |
| 1 | +import { Link, Outlet, useLocation, useNavigate, useParams } from "@tanstack/react-router"; | |
| 2 | 2 | import { useEffect, useRef, useState, type ReactNode } from "react"; |
| 3 | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| @@ -23,7 +23,6 @@ import { · 18 unmodified lines | ||
| 23 | 23 | } from "../components/integration-favicon"; |
| 24 | 24 | import { CommandPalette } from "../components/command-palette"; |
| 25 | 25 | import { Wordmark } from "../components/wordmark"; |
| 26 | −import { ConnectDialog } from "../pages/integrations"; | |
| 27 | 26 | import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 28 | 27 | import { useAuth } from "./auth-context"; |
| 29 | 28 | |
| @@ -428,7 +427,13 @@ export function Shell(props: ShellProps) { · 398 unmodified lines | ||
| 428 | 427 | const lastPathname = useRef(pathname); |
| 429 | 428 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); |
| 430 | 429 | const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); |
| 431 | − const [connectIntegrationOpen, setConnectIntegrationOpen] = useState(false); | |
| 430 | + const navigate = useNavigate(); | |
| 431 | + // The connect dialog became the full-page picker; the sidebar affordance | |
| 432 | + // navigates instead of opening a modal. | |
| 433 | + const openIntegrationBrowse = () => { | |
| 434 | + trackEvent("integration_browse_opened", { via: "sidebar" }); | |
| 435 | + void navigate({ to: "/{-$orgSlug}/integrations/browse" }); | |
| 436 | + }; | |
| 432 | 437 | if (lastPathname.current !== pathname) { |
| 433 | 438 | lastPathname.current = pathname; |
| 434 | 439 | if (mobileSidebarOpen) setMobileSidebarOpen(false); |
| @@ -446,17 +451,13 @@ export function Shell(props: ShellProps) { · 11 unmodified lines | ||
| 446 | 451 | return ( |
| 447 | 452 | <div className="flex h-screen overflow-hidden"> |
| 448 | 453 | <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} /> |
| 449 | − <ConnectDialog open={connectIntegrationOpen} onOpenChange={setConnectIntegrationOpen} /> | |
| 450 | 454 | {/* Desktop sidebar */} |
| 451 | 455 | <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56"> |
| 452 | 456 | <SidebarContent |
| 453 | 457 | {...props} |
| 454 | 458 | pathname={pathname} |
| 455 | 459 | onOpenCommands={() => setCommandPaletteOpen(true)} |
| 456 | − onOpenIntegrationConnect={() => { | |
| 457 | − setConnectIntegrationOpen(true); | |
| 458 | − trackEvent("integration_connect_dialog_opened"); | |
| 459 | − }} | |
| 460 | + onOpenIntegrationConnect={openIntegrationBrowse} | |
| 460 | 461 | /> |
| 461 | 462 | </aside> |
| 462 | 463 | |
| @@ -502,8 +503,7 @@ export function Shell(props: ShellProps) { · 39 unmodified lines | ||
| 502 | 503 | }} |
| 503 | 504 | onOpenIntegrationConnect={() => { |
| 504 | 505 | setMobileSidebarOpen(false); |
| 505 | − setConnectIntegrationOpen(true); | |
| 506 | − trackEvent("integration_connect_dialog_opened"); | |
| 506 | + openIntegrationBrowse(); | |
| 507 | 507 | }} |
| 508 | 508 | /> |
| 509 | 509 | </div> |
| @@ -15,9 +15,14 @@ export function AddIntegrationPage(props: { · 14 unmodified lines | ||
| 15 | 15 | url?: string; |
| 16 | 16 | preset?: string; |
| 17 | 17 | namespace?: string; |
| 18 | + authHeader?: string; | |
| 19 | + authNote?: string; | |
| 20 | + authKind?: string; | |
| 21 | + specOverrides?: string; | |
| 18 | 22 | }) { |
| 19 | 23 | useExecutorDocumentTitle("Add integration"); |
| 20 | − const { pluginKey, url, preset, namespace } = props; | |
| 24 | + const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } = | |
| 25 | + props; | |
| 21 | 26 | const navigate = useNavigate(); |
| 22 | 27 | const integrationPlugins = useIntegrationPlugins(); |
| 23 | 28 | const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); |
| @@ -57,6 +62,10 @@ export function AddIntegrationPage(props: { · 33 unmodified lines | ||
| 57 | 62 | initialUrl={url} |
| 58 | 63 | initialPreset={preset} |
| 59 | 64 | initialNamespace={namespace} |
| 65 | + initialAuthHeader={authHeader} | |
| 66 | + initialAuthNote={authNote} | |
| 67 | + initialAuthKind={authKind} | |
| 68 | + initialSpecOverrides={specOverrides} | |
| 60 | 69 | onComplete={(slug?: string) => { |
| 61 | 70 | trackEvent("integration_added", { |
| 62 | 71 | plugin_key: pluginKey, |
| @@ -0,0 +1,1041 @@ | ||
| 1 | +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 2 | +import { Link, useNavigate } from "@tanstack/react-router"; | |
| 3 | +import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 4 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 5 | +import * as Effect from "effect/Effect"; | |
| 6 | +import * as Exit from "effect/Exit"; | |
| 7 | +import { PlusIcon, SearchIcon } from "lucide-react"; | |
| 8 | +import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared"; | |
| 9 | +import { | |
| 10 | + useIntegrationPlugins, | |
| 11 | + type IntegrationPlugin, | |
| 12 | + type IntegrationPreset, | |
| 13 | + type IntegrationQuickAddInput, | |
| 14 | + type IntegrationQuickAddResult, | |
| 15 | +} from "@executor-js/sdk/client"; | |
| 16 | + | |
| 17 | +import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms"; | |
| 18 | +import { slugifyNamespace } from "../plugins/namespace"; | |
| 19 | +import { trackEvent } from "../api/analytics"; | |
| 20 | +import { Button } from "../components/button"; | |
| 21 | +import { Input } from "../components/input"; | |
| 22 | +import { PageHeader } from "../components/page"; | |
| 23 | +import { | |
| 24 | + integrationFaviconUrl, | |
| 25 | + integrationInferredUrl, | |
| 26 | + integrationPresetIconUrl, | |
| 27 | +} from "../components/integration-favicon"; | |
| 28 | +import { Skeleton } from "../components/skeleton"; | |
| 29 | +import { useExecutorDocumentTitle } from "../lib/document-title"; | |
| 30 | +import { | |
| 31 | + availableCatalogKinds, | |
| 32 | + catalogLogoUrl, | |
| 33 | + filterCatalogEntries, | |
| 34 | + resolveConnectTarget, | |
| 35 | + useCatalogBrowse, | |
| 36 | + type CatalogKind, | |
| 37 | + type CatalogSearchEntry, | |
| 38 | + type CatalogSurface, | |
| 39 | +} from "../lib/integrations-sh-catalog"; | |
| 40 | + | |
| 41 | +// --------------------------------------------------------------------------- | |
| 42 | +// The full-page integration picker. | |
| 43 | +// | |
| 44 | +// Replaces the connect dialog. Finding something to connect is the first task | |
| 45 | +// of onboarding, so it gets a page and a permanent, focused search field rather | |
| 46 | +// than a 16rem scroll window behind a header button. | |
| 47 | +// | |
| 48 | +// ONE ROW PER ADDABLE THING. A service exposing both an API and an MCP server | |
| 49 | +// yields two rows, because they really are two different integrations with | |
| 50 | +// different tools and different auth — but each row NAMES its surface | |
| 51 | +// ("Stripe API", "Stripe MCP") rather than repeating the bare service name and | |
| 52 | +// leaving a badge to carry the difference. Two rows reading "Stripe" look like | |
| 53 | +// a duplicate; two rows reading "Stripe API" and "Stripe MCP" look like a | |
| 54 | +// choice, which is what they are. | |
| 55 | +// | |
| 56 | +// THE REGISTRY IS THE LIST. Every connectable card is a registry row: the | |
| 57 | +// registry carries the name, domain, description, spec or endpoint, auth | |
| 58 | +// facts, and corrective spec overrides, and the spec itself declares how to | |
| 59 | +// authenticate — a deployment's first-party OAuth clients bind at connect | |
| 60 | +// time by endpoint host, not through the picker. The one exception is | |
| 61 | +// presets WITHOUT a connect URL (local-process servers like Chrome DevTools | |
| 62 | +// over stdio): those cannot be registry rows yet, so they remain cards. | |
| 63 | +// | |
| 64 | +// FACETS ARE SURFACE KIND, NOT CATEGORY. There is no taxonomy to facet on, so a | |
| 65 | +// category rail would have to invent one. Kind is real, filtered server-side. | |
| 66 | +// --------------------------------------------------------------------------- | |
| 67 | + | |
| 68 | +const KIND_TO_PLUGIN_KEY: Record<string, string> = { | |
| 69 | + openapi: "openapi", | |
| 70 | + mcp: "mcp", | |
| 71 | + graphql: "graphql", | |
| 72 | + googleDiscovery: "google", | |
| 73 | +}; | |
| 74 | + | |
| 75 | +const CATALOG_KIND_LABEL: Record<CatalogKind, string> = { | |
| 76 | + mcp: "MCP", | |
| 77 | + openapi: "OpenAPI", | |
| 78 | + graphql: "GraphQL", | |
| 79 | +}; | |
| 80 | + | |
| 81 | +/** How a surface is said in a row's NAME. Deliberately not the facet | |
| 82 | + * vocabulary: a facet filters on the spec format ("OpenAPI"), while a name | |
| 83 | + * says the thing you would say out loud ("Stripe API"). */ | |
| 84 | +const SURFACE_WORD: Record<string, string> = { | |
| 85 | + mcp: "MCP", | |
| 86 | + openapi: "API", | |
| 87 | + google: "API", | |
| 88 | + graphql: "GraphQL", | |
| 89 | +}; | |
| 90 | + | |
| 91 | +/** `Stripe` + API → `Stripe API`, but `GitHub REST` + API stays `GitHub REST` | |
| 92 | + * and `Emulate MCP` + MCP stays `Emulate MCP`. Appending a word the name | |
| 93 | + * already carries reads worse than leaving it off. */ | |
| 94 | +const withSurface = (name: string, surface: string): string => { | |
| 95 | + const lower = name.toLowerCase(); | |
| 96 | + const already = | |
| 97 | + lower.includes(surface.toLowerCase()) || | |
| 98 | + (surface === "API" && (lower.includes("api") || lower.includes("rest"))); | |
| 99 | + return already ? name : `${name} ${surface}`; | |
| 100 | +}; | |
| 101 | + | |
| 102 | +/** The key on which a preset and a catalog row count as the same product: | |
| 103 | + * the name with surface words and punctuation stripped, plus the kind. | |
| 104 | + * "GitHub REST" and "GitHub API" are one product; "Stripe API" and | |
| 105 | + * "Stripe MCP" are not. */ | |
| 106 | +const productKey = (name: string, kind: string): string => | |
| 107 | + `${name | |
| 108 | + .toLowerCase() | |
| 109 | + .replace(/\b(api|rest|graphql|mcp)\b/g, "") | |
| 110 | + .replace(/[^a-z0-9]/g, "")}|${kind}`; | |
| 111 | + | |
| 112 | +const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = { | |
| 113 | + high: 3, | |
| 114 | + medium: 2, | |
| 115 | + low: 1, | |
| 116 | +}; | |
| 117 | + | |
| 118 | +const bestDetection = ( | |
| 119 | + results: readonly IntegrationDetectionResult[], | |
| 120 | +): IntegrationDetectionResult | undefined => | |
| 121 | + [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; | |
| 122 | + | |
| 123 | +/** The input either names a thing to look for or points at one. Anything with | |
| 124 | + * a scheme, a slash, or a host-with-TLD is a URL; everything else is a query. */ | |
| 125 | +const looksLikeUrl = (raw: string): boolean => { | |
| 126 | + const value = raw.trim(); | |
| 127 | + if (value.length === 0) return false; | |
| 128 | + if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(value)) return true; | |
| 129 | + if (value.includes("/")) return true; | |
| 130 | + if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(value)) return true; | |
| 131 | + return false; | |
| 132 | +}; | |
| 133 | + | |
| 134 | +/** A readable product name for a bare domain: `gmail.googleapis.com` reads as | |
| 135 | + * "Gmail", `linear.app` as "Linear". The full domain still renders beside it, | |
| 136 | + * because two services can share a leading label and the domain is what | |
| 137 | + * actually disambiguates them. */ | |
| 138 | +const domainDisplayName = (domain: string): string => { | |
| 139 | + const host = domain.replace(/^www\./, ""); | |
| 140 | + const label = host.split(".")[0] ?? host; | |
| 141 | + return label.charAt(0).toUpperCase() + label.slice(1); | |
| 142 | +}; | |
| 143 | + | |
| 144 | +/** The registry truncates descriptions to a fixed width, which lands mid-word | |
| 145 | + * ("…read, manage, and send m"). Trim back to the last whole word so the cut | |
| 146 | + * reads as deliberate. */ | |
| 147 | +const tidyDescription = (text: string): string => { | |
| 148 | + const trimmed = text.trim(); | |
| 149 | + if (trimmed.length === 0) return trimmed; | |
| 150 | + if (/[.!?]$/.test(trimmed)) return trimmed; | |
| 151 | + const lastSpace = trimmed.lastIndexOf(" "); | |
| 152 | + return `${(lastSpace > 40 ? trimmed.slice(0, lastSpace) : trimmed).replace(/[,;:]$/, "")}…`; | |
| 153 | +}; | |
| 154 | + | |
| 155 | +type PresetEntry = { | |
| 156 | + readonly preset: IntegrationPreset; | |
| 157 | + readonly pluginKey: string; | |
| 158 | + readonly pluginLabel: string; | |
| 159 | +}; | |
| 160 | + | |
| 161 | +interface Row { | |
| 162 | + readonly key: string; | |
| 163 | + readonly testId: string; | |
| 164 | + readonly title: string; | |
| 165 | + /** Surface kind for product matching (productKey); not rendered. */ | |
| 166 | + readonly kindKey: string; | |
| 167 | + /** The domain, shown beside a prettified title so near-namesakes stay | |
| 168 | + * distinguishable. Omitted when the title is already the domain. */ | |
| 169 | + readonly domain?: string; | |
| 170 | + readonly description?: string; | |
| 171 | + readonly iconUrl?: string; | |
| 172 | + readonly onSelect: () => void; | |
| 173 | + readonly added: boolean; | |
| 174 | + /** Namespace of the installed integration this row matched — the View | |
| 175 | + * destination. Known for quick-added rows and for rows whose installed | |
| 176 | + * match came from the catalog list. */ | |
| 177 | + readonly viewSlug?: string; | |
| 178 | + /** Added DURING this picker session (quick add). Excluded from the | |
| 179 | + * added-first float: the card the user just clicked must keep its place — | |
| 180 | + * teleporting it to the top mid-interaction is exactly the jumping this | |
| 181 | + * page keeps having to unlearn. It floats on the next visit. */ | |
| 182 | + readonly freshlyAdded?: boolean; | |
| 183 | + readonly busy: boolean; | |
| 184 | + /** A click-time failure, rendered on this card rather than page-top. */ | |
| 185 | + readonly error?: string; | |
| 186 | +} | |
| 187 | + | |
| 188 | +// --------------------------------------------------------------------------- | |
| 189 | +// Infinite scroll | |
| 190 | +// --------------------------------------------------------------------------- | |
| 191 | + | |
| 192 | +/** Fires `onVisible` whenever the sentinel scrolls near the viewport. The | |
| 193 | + * rootMargin starts the next page a screen early, so scrolling never visibly | |
| 194 | + * hits the bottom of a still-growing list. */ | |
| 195 | +function LoadMoreSentinel(props: { readonly onVisible: () => void }) { | |
| 196 | + const ref = useRef<HTMLDivElement | null>(null); | |
| 197 | + const { onVisible } = props; | |
| 198 | + useEffect(() => { | |
| 199 | + const node = ref.current; | |
| 200 | + if (!node) return; | |
| 201 | + const observer = new IntersectionObserver( | |
| 202 | + (observed) => { | |
| 203 | + if (observed.some((entry) => entry.isIntersecting)) onVisible(); | |
| 204 | + }, | |
| 205 | + { rootMargin: "600px" }, | |
| 206 | + ); | |
| 207 | + observer.observe(node); | |
| 208 | + return () => observer.disconnect(); | |
| 209 | + }, [onVisible]); | |
| 210 | + return <div ref={ref} aria-hidden className="h-px" />; | |
| 211 | +} | |
| 212 | + | |
| 213 | +// --------------------------------------------------------------------------- | |
| 214 | +// Row | |
| 215 | +// --------------------------------------------------------------------------- | |
| 216 | + | |
| 217 | +/** Registry logos 404 for plenty of hosts; a broken-image glyph in a list of | |
| 218 | + * brand marks looks like a bug, so failures fall back to a neutral mark. */ | |
| 219 | +function RowIcon(props: { readonly src?: string; readonly alt: string }) { | |
| 220 | + const [failed, setFailed] = useState(false); | |
| 221 | + if (!props.src || failed) { | |
| 222 | + return ( | |
| 223 | + <span | |
| 224 | + aria-hidden | |
| 225 | + className="flex size-5 items-center justify-center rounded-sm bg-muted text-[10px] font-medium text-muted-foreground" | |
| 226 | + > | |
| 227 | + {props.alt.charAt(0).toUpperCase()} | |
| 228 | + </span> | |
| 229 | + ); | |
| 230 | + } | |
| 231 | + return ( | |
| 232 | + <img | |
| 233 | + src={props.src} | |
| 234 | + alt="" | |
| 235 | + width={20} | |
| 236 | + height={20} | |
| 237 | + // NOT lazy. The console scrolls inside a nested container, and Chrome's | |
| 238 | + // lazy loading never brings these into view there — every icon sits | |
| 239 | + // pending forever while an eager load of the same URL succeeds instantly. | |
| 240 | + // A 20px favicon is not worth deferring anyway. | |
| 241 | + onError={() => setFailed(true)} | |
| 242 | + className="size-5 object-contain" | |
| 243 | + /> | |
| 244 | + ); | |
| 245 | +} | |
| 246 | + | |
| 247 | +function ResultCard(props: { readonly row: Row }) { | |
| 248 | + const { row } = props; | |
| 249 | + return ( | |
| 250 | + <div | |
| 251 | + data-testid={row.testId} | |
| 252 | + className="flex min-h-[8.5rem] flex-col gap-2.5 rounded-lg border border-border/60 p-4 transition-colors hover:border-border hover:bg-accent/20" | |
| 253 | + > | |
| 254 | + <div className="flex items-start justify-between gap-2"> | |
| 255 | + <span className="flex size-8 shrink-0 items-center justify-center"> | |
| 256 | + <RowIcon {...(row.iconUrl ? { src: row.iconUrl } : {})} alt={row.title} /> | |
| 257 | + </span> | |
| 258 | + {row.added ? ( | |
| 259 | + // The add happened HERE — the card is the receipt. View jumps to | |
| 260 | + // the integration's hub, where authenticating happens. | |
| 261 | + row.viewSlug ? ( | |
| 262 | + <Button asChild variant="outline" size="sm" aria-label={`View ${row.title}`}> | |
| 263 | + <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: row.viewSlug }}> | |
| 264 | + View | |
| 265 | + </Link> | |
| 266 | + </Button> | |
| 267 | + ) : ( | |
| 268 | + <span className="px-2 py-1.5 text-xs font-medium text-muted-foreground">Added</span> | |
| 269 | + ) | |
| 270 | + ) : row.busy ? ( | |
| 271 | + <span className="px-2 py-1.5 text-xs text-muted-foreground">Adding…</span> | |
| 272 | + ) : ( | |
| 273 | + <Button | |
| 274 | + type="button" | |
| 275 | + variant="outline" | |
| 276 | + size="sm" | |
| 277 | + onClick={row.onSelect} | |
| 278 | + // Every card's button reads "Add", so the visible label alone is | |
| 279 | + // useless to a screen reader; the accessible name carries the card. | |
| 280 | + aria-label={`Add ${row.title}`} | |
| 281 | + > | |
| 282 | + Add | |
| 283 | + </Button> | |
| 284 | + )} | |
| 285 | + </div> | |
| 286 | + <div className="min-w-0"> | |
| 287 | + <p className="truncate text-sm font-medium text-foreground">{row.title}</p> | |
| 288 | + {row.domain ? ( | |
| 289 | + <p className="truncate font-mono text-[11px] text-muted-foreground/70">{row.domain}</p> | |
| 290 | + ) : null} | |
| 291 | + </div> | |
| 292 | + {row.description ? ( | |
| 293 | + <p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground"> | |
| 294 | + {row.description} | |
| 295 | + </p> | |
| 296 | + ) : null} | |
| 297 | + {row.error ? ( | |
| 298 | + <p role="alert" className="text-xs text-destructive"> | |
| 299 | + {row.error} | |
| 300 | + </p> | |
| 301 | + ) : null} | |
| 302 | + </div> | |
| 303 | + ); | |
| 304 | +} | |
| 305 | + | |
| 306 | +function CardSkeleton(props: { readonly index: number }) { | |
| 307 | + const { index } = props; | |
| 308 | + return ( | |
| 309 | + <div className="flex flex-col gap-2.5 rounded-lg border border-border/60 p-4"> | |
| 310 | + <div className="flex items-start justify-between gap-2"> | |
| 311 | + <Skeleton className="size-8 rounded-md" /> | |
| 312 | + <Skeleton className="h-8 w-16 rounded-md" /> | |
| 313 | + </div> | |
| 314 | + <div className="space-y-1.5"> | |
| 315 | + <Skeleton className="h-3.5" style={{ width: `${40 + ((index * 11) % 30)}%` }} /> | |
| 316 | + <Skeleton className="h-3" style={{ width: `${55 + ((index * 13) % 30)}%` }} /> | |
| 317 | + </div> | |
| 318 | + <Skeleton className="h-3" style={{ width: `${70 + ((index * 7) % 25)}%` }} /> | |
| 319 | + </div> | |
| 320 | + ); | |
| 321 | +} | |
| 322 | + | |
| 323 | +// --------------------------------------------------------------------------- | |
| 324 | +// Quick add | |
| 325 | +// --------------------------------------------------------------------------- | |
| 326 | + | |
| 327 | +type QuickAddFn = (input: IntegrationQuickAddInput) => Promise<IntegrationQuickAddResult>; | |
| 328 | + | |
| 329 | +/** Collects each plugin's bound quick-add callback into the page's map. | |
| 330 | + * A component per plugin, not a hook over a list: `useQuickAdd` is a hook, | |
| 331 | + * and mounting one bridge per plugin is how a fixed set of per-plugin hooks | |
| 332 | + * stays rules-of-hooks clean. */ | |
| 333 | +function QuickAddBridge(props: { | |
| 334 | + readonly pluginKey: string; | |
| 335 | + /** The plugin's hook, called UNCONDITIONALLY — the host renders a bridge | |
| 336 | + * only for plugins that have one, so this component's hook sequence never | |
| 337 | + * depends on a plugin object's shape. */ | |
| 338 | + readonly useQuickAdd: () => QuickAddFn; | |
| 339 | + readonly register: (key: string, fn: QuickAddFn | null) => void; | |
| 340 | +}) { | |
| 341 | + const { pluginKey, useQuickAdd, register } = props; | |
| 342 | + const fn = useQuickAdd(); | |
| 343 | + useEffect(() => { | |
| 344 | + register(pluginKey, fn); | |
| 345 | + return () => register(pluginKey, null); | |
| 346 | + }, [pluginKey, fn, register]); | |
| 347 | + return null; | |
| 348 | +} | |
| 349 | + | |
| 350 | +/** One bridge per plugin KEY: a duplicate key would share the React key and | |
| 351 | + * the callback slot, letting either copy's cleanup delete the other. */ | |
| 352 | +function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { | |
| 353 | + const seen = new Set<string>(); | |
| 354 | + return plugins.filter((plugin) => { | |
| 355 | + if (!plugin.useQuickAdd || seen.has(plugin.key)) return false; | |
| 356 | + seen.add(plugin.key); | |
| 357 | + return true; | |
| 358 | + }); | |
| 359 | +} | |
| 360 | + | |
| 361 | +// --------------------------------------------------------------------------- | |
| 362 | +// Page | |
| 363 | +// --------------------------------------------------------------------------- | |
| 364 | + | |
| 365 | +export function IntegrationBrowsePage() { | |
| 366 | + useExecutorDocumentTitle("Add an integration"); | |
| 367 | + const navigate = useNavigate(); | |
| 368 | + const integrationPlugins = useIntegrationPlugins(); | |
| 369 | + const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); | |
| 370 | + const installed = useAtomValue(integrationsOptimisticAtom); | |
| 371 | + | |
| 372 | + const [query, setQuery] = useState(""); | |
| 373 | + const [detecting, setDetecting] = useState(false); | |
| 374 | + const [error, setError] = useState<string | null>(null); | |
| 375 | + // Resolve-on-click failures belong on the card that was clicked — a page-top | |
| 376 | + // alert for a card three screens down reads as a silent failure. | |
| 377 | + const [rowError, setRowError] = useState<{ | |
| 378 | + readonly key: string; | |
| 379 | + readonly message: string; | |
| 380 | + } | null>(null); | |
| 381 | + const [resolvingDomain, setResolvingDomain] = useState<string | null>(null); | |
| 382 | + // One-click adds run per-card and CONCURRENTLY — adding Linear must not | |
| 383 | + // lock the Notion card. Keyed by row error-key (`domain|kind`). | |
| 384 | + const [quickAddingKeys, setQuickAddingKeys] = useState<ReadonlySet<string>>(new Set()); | |
| 385 | + // Slugs of integrations added from this page, keyed the same way, so the | |
| 386 | + // card can flip to View without waiting for the catalog refetch. | |
| 387 | + const [quickAddedSlugs, setQuickAddedSlugs] = useState<ReadonlyMap<string, string>>(new Map()); | |
| 388 | + const quickAdders = useRef(new Map<string, QuickAddFn>()); | |
| 389 | + const registerQuickAdd = useCallback((key: string, fn: QuickAddFn | null) => { | |
| 390 | + if (fn) quickAdders.current.set(key, fn); | |
| 391 | + else quickAdders.current.delete(key); | |
| 392 | + }, []); | |
| 393 | + | |
| 394 | + const isUrl = looksLikeUrl(query); | |
| 395 | + // A URL is a destination, not a filter — but half-typed URLs pass through | |
| 396 | + // here on every keystroke ("stripe.co" parses as one), and swapping the | |
| 397 | + // grid back to the browse head mid-typing made the whole page jump. Freeze | |
| 398 | + // the list on the last real query instead; the detect hint is what changes. | |
| 399 | + const lastTextQuery = useRef(""); | |
| 400 | + if (!isUrl) lastTextQuery.current = query; | |
| 401 | + const listQuery = isUrl ? lastTextQuery.current : query; | |
| 402 | + const text = listQuery.trim().toLowerCase(); | |
| 403 | + | |
| 404 | + const availableKinds = useMemo( | |
| 405 | + () => availableCatalogKinds(integrationPlugins), | |
| 406 | + [integrationPlugins], | |
| 407 | + ); | |
| 408 | + // Nothing to exclude now that the registry is the only source. | |
| 409 | + const excludeDomains = useMemo(() => new Set<string>(), []); | |
| 410 | + | |
| 411 | + const catalog = useCatalogBrowse({ query: listQuery }); | |
| 412 | + const catalogEntries = useMemo(() => { | |
| 413 | + const usable = filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds }); | |
| 414 | + if (!catalog.stale) return usable; | |
| 415 | + // Held-over results from the previous query: keep only what still reads | |
| 416 | + // as an answer to the live text. Every whitespace token must appear in | |
| 417 | + // the entry's name or domain — refining "google"→"google cal" keeps | |
| 418 | + // cloud.google.com rows (the match lives in the domain), while a new | |
| 419 | + // word drops the old rows instead of parading them under the wrong | |
| 420 | + // query, which is how a calendar search showed Gmail. An empty text | |
| 421 | + // (clearing back to browse) holds nothing. | |
| 422 | + const tokens = listQuery.trim().toLowerCase().split(/\s+/).filter(Boolean); | |
| 423 | + if (tokens.length === 0) return []; | |
| 424 | + return usable.filter((entry) => { | |
| 425 | + const haystack = | |
| 426 | + `${entry.name ?? domainDisplayName(entry.domain)} ${entry.domain}`.toLowerCase(); | |
| 427 | + return tokens.every((token) => haystack.includes(token)); | |
| 428 | + }); | |
| 429 | + }, [catalog.entries, catalog.stale, listQuery, excludeDomains, availableKinds]); | |
| 430 | + | |
| 431 | + /** What is already connected, as `<slug>:<kind>`. | |
| 432 | + * | |
| 433 | + * The slug is the closest thing to an identity available today — it is the | |
| 434 | + * namespace the add flow seeds from a registry surface's `slug` or a | |
| 435 | + * preset's name. Kind is carried alongside it because slugs are NOT unique | |
| 436 | + * per surface: the OpenAPI and MCP presets for Stripe, Neon, Sentry and | |
| 437 | + * Axiom share a name and an id, so both seed the same namespace. Without the | |
| 438 | + * kind, adding one marks the other added — the same false positive the | |
| 439 | + * earlier domain match produced. | |
| 440 | + * | |
| 441 | + * This still under-reports: a connection the user renamed on the way in no | |
| 442 | + * longer matches, and reads as not-added. That is deliberate — offering to | |
| 443 | + * add something twice is recoverable, claiming something is connected when | |
| 444 | + * it is not is not. The real fix is recording at add time which row an | |
| 445 | + * integration came from; nothing here can infer it after the fact. */ | |
| 446 | + const installedKeys = useMemo(() => { | |
| 447 | + const rows: readonly Integration[] = AsyncResult.isSuccess(installed) ? installed.value : []; | |
| 448 | + return new Set( | |
| 449 | + rows.map((row) => `${String(row.slug)}:${KIND_TO_PLUGIN_KEY[row.kind] ?? row.kind}`), | |
| 450 | + ); | |
| 451 | + }, [installed]); | |
| 452 | + | |
| 453 | + const isAdded = useCallback( | |
| 454 | + (kind: string, ...candidates: readonly (string | undefined)[]): boolean => | |
| 455 | + candidates.some((candidate) => { | |
| 456 | + if (!candidate) return false; | |
| 457 | + return ( | |
| 458 | + installedKeys.has(`${candidate}:${kind}`) || | |
| 459 | + installedKeys.has(`${slugifyNamespace(candidate)}:${kind}`) | |
| 460 | + ); | |
| 461 | + }), | |
| 462 | + [installedKeys], | |
| 463 | + ); | |
| 464 | + | |
| 465 | + /** The installed integration's own namespace for an added row — the View | |
| 466 | + * button's destination. Same matching rules as `isAdded`. */ | |
| 467 | + const installedSlugFor = useCallback( | |
| 468 | + (kind: string, ...candidates: readonly (string | undefined)[]): string | undefined => { | |
| 469 | + for (const candidate of candidates) { | |
| 470 | + if (!candidate) continue; | |
| 471 | + if (installedKeys.has(`${candidate}:${kind}`)) return candidate; | |
| 472 | + const slugified = slugifyNamespace(candidate); | |
| 473 | + if (installedKeys.has(`${slugified}:${kind}`)) return slugified; | |
| 474 | + } | |
| 475 | + return undefined; | |
| 476 | + }, | |
| 477 | + [installedKeys], | |
| 478 | + ); | |
| 479 | + | |
| 480 | + const allPresets = useMemo(() => { | |
| 481 | + const entries: PresetEntry[] = []; | |
| 482 | + for (const plugin of integrationPlugins) { | |
| 483 | + for (const preset of plugin.presets ?? []) { | |
| 484 | + entries.push({ preset, pluginKey: plugin.key, pluginLabel: plugin.label }); | |
| 485 | + } | |
| 486 | + } | |
| 487 | + return entries; | |
| 488 | + }, [integrationPlugins]); | |
| 489 | + | |
| 490 | + const handleDetect = useCallback(async () => { | |
| 491 | + const trimmed = query.trim(); | |
| 492 | + if (trimmed.length === 0) return; | |
| 493 | + setDetecting(true); | |
| 494 | + setError(null); | |
| 495 | + // Detection is read-only — it inspects a URL and returns candidates without | |
| 496 | + // mutating the catalog, so it invalidates nothing. | |
| 497 | + const exit = await doDetect({ payload: { url: trimmed }, reactivityKeys: [] }); | |
| 498 | + if (Exit.isFailure(exit)) { | |
| 499 | + trackEvent("integration_detect_submitted", { success: false }); | |
| 500 | + setError("Couldn't reach that URL. Check it, or start from scratch below."); | |
| 501 | + setDetecting(false); | |
| 502 | + return; | |
| 503 | + } | |
| 504 | + const detected = bestDetection(exit.value); | |
| 505 | + if (!detected) { | |
| 506 | + trackEvent("integration_detect_submitted", { success: false }); | |
| 507 | + setError("Couldn't tell what that URL exposes. Start from scratch below."); | |
| 508 | + setDetecting(false); | |
| 509 | + return; | |
| 510 | + } | |
| 511 | + trackEvent("integration_detect_submitted", { | |
| 512 | + success: true, | |
| 513 | + detected_kind: detected.kind, | |
| 514 | + confidence: detected.confidence, | |
| 515 | + }); | |
| 516 | + const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind] ?? detected.kind; | |
| 517 | + if (!integrationPlugins.some((plugin) => plugin.key === pluginKey)) { | |
| 518 | + setError(`That looks like a ${detected.kind} integration, which this server can't add.`); | |
| 519 | + setDetecting(false); | |
| 520 | + return; | |
| 521 | + } | |
| 522 | + trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" }); | |
| 523 | + void navigate({ | |
| 524 | + to: "/{-$orgSlug}/integrations/add/$pluginKey", | |
| 525 | + params: { pluginKey }, | |
| 526 | + search: { url: trimmed, namespace: detected.slug }, | |
| 527 | + }); | |
| 528 | + }, [query, doDetect, navigate, integrationPlugins]); | |
| 529 | + | |
| 530 | + const goToAdd = useCallback( | |
| 531 | + (input: { | |
| 532 | + readonly kind: string; | |
| 533 | + readonly url: string; | |
| 534 | + readonly slug?: string; | |
| 535 | + readonly domain: string; | |
| 536 | + readonly auth?: CatalogSurface["auth"]; | |
| 537 | + readonly specOverrides?: CatalogSurface["specOverrides"]; | |
| 538 | + }) => { | |
| 539 | + trackEvent("integration_add_started", { | |
| 540 | + plugin_key: input.kind, | |
| 541 | + via: "catalog", | |
| 542 | + catalog_domain: input.domain, | |
| 543 | + }); | |
| 544 | + void navigate({ | |
| 545 | + to: "/{-$orgSlug}/integrations/add/$pluginKey", | |
| 546 | + params: { pluginKey: input.kind }, | |
| 547 | + search: { | |
| 548 | + url: input.url, | |
| 549 | + ...(input.slug ? { namespace: input.slug } : {}), | |
| 550 | + ...(input.auth?.header ? { authHeader: input.auth.header } : {}), | |
| 551 | + ...(input.auth?.note ? { authNote: input.auth.note } : {}), | |
| 552 | + ...(input.auth?.kind ? { authKind: input.auth.kind } : {}), | |
| 553 | + ...(input.specOverrides ? { specOverrides: JSON.stringify(input.specOverrides) } : {}), | |
| 554 | + }, | |
| 555 | + }); | |
| 556 | + }, | |
| 557 | + [navigate], | |
| 558 | + ); | |
| 559 | + | |
| 560 | + /** One-click add, in place. The registry knew the URL and the auth | |
| 561 | + * indicators, so there is nothing to configure — register directly and | |
| 562 | + * flip the card to View, leaving the user on this page to keep adding. | |
| 563 | + * Returns false when it could not (no quick-add for the kind, probe or | |
| 564 | + * registration failed), and the caller falls back to the configuration | |
| 565 | + * screen, which renders the same failure with full context. */ | |
| 566 | + const tryQuickAdd = useCallback( | |
| 567 | + async (input: { | |
| 568 | + readonly kind: CatalogKind; | |
| 569 | + readonly url: string; | |
| 570 | + readonly title: string; | |
| 571 | + readonly domain: string; | |
| 572 | + /** The row's identity key — carries the PRODUCT, not just domain|kind: | |
| 573 | + * one domain can offer two products of the same kind (the Google | |
| 574 | + * Photos Library and Picker), and a domain-level key marked both cards | |
| 575 | + * added when either one was. */ | |
| 576 | + readonly rowKey: string; | |
| 577 | + readonly slug?: string; | |
| 578 | + readonly auth?: CatalogSurface["auth"]; | |
| 579 | + readonly specOverrides?: CatalogSurface["specOverrides"]; | |
| 580 | + }): Promise<boolean> => { | |
| 581 | + const fn = quickAdders.current.get(KIND_TO_PLUGIN_KEY[input.kind] ?? input.kind); | |
| 582 | + if (!fn) return false; | |
| 583 | + const rowKey = input.rowKey; | |
| 584 | + setQuickAddingKeys((previous) => new Set(previous).add(rowKey)); | |
| 585 | + trackEvent("integration_add_started", { | |
| 586 | + plugin_key: input.kind, | |
| 587 | + via: "catalog", | |
| 588 | + catalog_domain: input.domain, | |
| 589 | + }); | |
| 590 | + const result = await fn({ | |
| 591 | + url: input.url, | |
| 592 | + name: input.title, | |
| 593 | + domain: input.domain, | |
| 594 | + ...(input.slug ? { slug: input.slug } : {}), | |
| 595 | + ...(input.auth?.header ? { authHeader: input.auth.header } : {}), | |
| 596 | + ...(input.auth?.kind ? { authKind: input.auth.kind } : {}), | |
| 597 | + ...(input.specOverrides ? { specOverrides: input.specOverrides } : {}), | |
| 598 | + }); | |
| 599 | + setQuickAddingKeys((previous) => { | |
| 600 | + const next = new Set(previous); | |
| 601 | + next.delete(rowKey); | |
| 602 | + return next; | |
| 603 | + }); | |
| 604 | + if (!result.ok) return false; | |
| 605 | + setQuickAddedSlugs((previous) => new Map(previous).set(rowKey, result.slug)); | |
| 606 | + return true; | |
| 607 | + }, | |
| 608 | + [], | |
| 609 | + ); | |
| 610 | + | |
| 611 | + const pickCatalogEntry = useCallback( | |
| 612 | + async ( | |
| 613 | + entry: CatalogSearchEntry, | |
| 614 | + kind: CatalogKind, | |
| 615 | + title: string, | |
| 616 | + rowKey: string, | |
| 617 | + // The EXACT surface this card was built from. Never re-derived from | |
| 618 | + // `kind`: one domain can carry two same-kind products (Google Photos | |
| 619 | + // Library and Picker), and a kind lookup silently merged the clicked | |
| 620 | + // card's URL with the FIRST surface's slug, auth, and overrides. | |
| 621 | + surface?: CatalogSurface, | |
| 622 | + ) => { | |
| 623 | + const knownUrl = surface?.url; | |
| 624 | + if (knownUrl) { | |
| 625 | + const added = await tryQuickAdd({ | |
| 626 | + kind, | |
| 627 | + url: knownUrl, | |
| 628 | + title, | |
| 629 | + domain: entry.domain, | |
| 630 | + rowKey, | |
| 631 | + ...(surface ? { slug: surface.slug } : {}), | |
| 632 | + ...(surface?.auth ? { auth: surface.auth } : {}), | |
| 633 | + ...(surface?.specOverrides ? { specOverrides: surface.specOverrides } : {}), | |
| 634 | + }); | |
| 635 | + if (added) return; | |
| 636 | + goToAdd({ | |
| 637 | + kind, | |
| 638 | + url: knownUrl, | |
| 639 | + domain: entry.domain, | |
| 640 | + ...(surface ? { slug: surface.slug } : {}), | |
| 641 | + ...(surface?.auth ? { auth: surface.auth } : {}), | |
| 642 | + ...(surface?.specOverrides ? { specOverrides: surface.specOverrides } : {}), | |
| 643 | + }); | |
| 644 | + return; | |
| 645 | + } | |
| 646 | + if (resolvingDomain !== null) return; | |
| 647 | + setResolvingDomain(entry.domain); | |
| 648 | + setRowError(null); | |
| 649 | + // The row's product name disambiguates same-kind surfaces in the doc | |
| 650 | + // (the registry row itself was kinds-only, so the name is all we have). | |
| 651 | + const exit = await Effect.runPromiseExit( | |
| 652 | + resolveConnectTarget(entry.domain, [kind], entry.name ?? undefined), | |
| 653 | + ); | |
| 654 | + setResolvingDomain(null); | |
| 655 | + if (Exit.isFailure(exit) || !exit.value) { | |
| 656 | + setRowError({ | |
| 657 | + key: rowKey, | |
| 658 | + message: "Couldn't load connect details. Paste its URL above instead.", | |
| 659 | + }); | |
| 660 | + return; | |
| 661 | + } | |
| 662 | + const target = exit.value; | |
| 663 | + const added = await tryQuickAdd({ | |
| 664 | + kind: target.kind, | |
| 665 | + url: target.url, | |
| 666 | + title, | |
| 667 | + domain: entry.domain, | |
| 668 | + rowKey, | |
| 669 | + ...(target.slug ? { slug: target.slug } : {}), | |
| 670 | + }); | |
| 671 | + if (added) return; | |
| 672 | + goToAdd({ | |
| 673 | + kind: target.kind, | |
| 674 | + url: target.url, | |
| 675 | + domain: entry.domain, | |
| 676 | + ...(target.slug ? { slug: target.slug } : {}), | |
| 677 | + }); | |
| 678 | + }, | |
| 679 | + [goToAdd, resolvingDomain, tryQuickAdd], | |
| 680 | + ); | |
| 681 | + | |
| 682 | + const pickPreset = useCallback( | |
| 683 | + (entry: PresetEntry) => { | |
| 684 | + trackEvent("integration_add_started", { | |
| 685 | + plugin_key: entry.pluginKey, | |
| 686 | + via: "preset", | |
| 687 | + preset_id: entry.preset.id, | |
| 688 | + }); | |
| 689 | + void navigate({ | |
| 690 | + to: "/{-$orgSlug}/integrations/add/$pluginKey", | |
| 691 | + params: { pluginKey: entry.pluginKey }, | |
| 692 | + search: { preset: entry.preset.id }, | |
| 693 | + }); | |
| 694 | + }, | |
| 695 | + [navigate], | |
| 696 | + ); | |
| 697 | + | |
| 698 | + // --- Preset rows --------------------------------------------------------- | |
| 699 | + // | |
| 700 | + // Only presets WITHOUT a connect URL remain cards: a preset with a URL is a | |
| 701 | + // registry row's job now, while a local-process server (Chrome DevTools over | |
| 702 | + // stdio) has no registry representation yet. | |
| 703 | + const presetRows = useMemo<readonly Row[]>(() => { | |
| 704 | + const rows: Row[] = []; | |
| 705 | + for (const entry of allPresets) { | |
| 706 | + // A URL preset the registry lists is the registry row's job. A custom | |
| 707 | + // deployment preset has no registry row — hiding it would make a | |
| 708 | + // private API undiscoverable, so it keeps its card. | |
| 709 | + if ( | |
| 710 | + (entry.preset.url !== undefined || entry.preset.endpoint !== undefined) && | |
| 711 | + entry.preset.registryListed === true | |
| 712 | + ) | |
| 713 | + continue; | |
| 714 | + if (text.length > 0) { | |
| 715 | + const corpus = | |
| 716 | + `${entry.preset.name} ${entry.preset.summary ?? ""} ${entry.preset.family ?? ""} ${entry.pluginLabel}`.toLowerCase(); | |
| 717 | + if (!corpus.includes(text)) continue; | |
| 718 | + } | |
| 719 | + const surface = SURFACE_WORD[entry.pluginKey] ?? entry.pluginLabel; | |
| 720 | + const title = withSurface(entry.preset.name, surface); | |
| 721 | + const kindKey = entry.pluginKey === "google" ? "openapi" : entry.pluginKey; | |
| 722 | + rows.push({ | |
| 723 | + key: `preset-${entry.pluginKey}-${entry.preset.id}`, | |
| 724 | + testId: `preset-${entry.pluginKey}-${entry.preset.id}`, | |
| 725 | + title, | |
| 726 | + kindKey, | |
| 727 | + ...(entry.preset.summary ? { description: entry.preset.summary } : {}), | |
| 728 | + ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}), | |
| 729 | + onSelect: () => pickPreset(entry), | |
| 730 | + // The rendered title is also what the add flow derives the namespace | |
| 731 | + // from, so the card recognises an add made through it even when the | |
| 732 | + // preset declares no defaultSlug and its bare name would miss. | |
| 733 | + added: isAdded(entry.pluginKey, entry.preset.defaultSlug, entry.preset.name, title), | |
| 734 | + busy: false, | |
| 735 | + }); | |
| 736 | + } | |
| 737 | + return rows; | |
| 738 | + }, [allPresets, text, pickPreset, isAdded]); | |
| 739 | + | |
| 740 | + // --- Catalog rows: one per (service, surface) ----------------------------- | |
| 741 | + const catalogRows = useMemo<readonly Row[]>(() => { | |
| 742 | + let rows = catalogEntries.flatMap((entry): readonly Row[] => { | |
| 743 | + const pretty = entry.name ?? domainDisplayName(entry.domain); | |
| 744 | + const description = entry.description ? tidyDescription(entry.description) : undefined; | |
| 745 | + // Prefer the registry's own per-surface records, and within them only | |
| 746 | + // the surfaces that carry a connect target: the registry can know a | |
| 747 | + // surface EXISTS without knowing where it lives (conjur.org's OpenAPI | |
| 748 | + // has no recorded spec URL), and a card whose Add can only fail is | |
| 749 | + // worse than no card. Kinds-only entries (older registries, stubs) keep | |
| 750 | + // the resolve-on-click fallback. | |
| 751 | + const surfaces: readonly (CatalogSurface | { readonly kind: CatalogKind })[] = | |
| 752 | + entry.surfaces && entry.surfaces.length > 0 | |
| 753 | + ? entry.surfaces.filter((surface) => surface.url) | |
| 754 | + : entry.kinds.map((kind) => ({ kind })); | |
| 755 | + return surfaces.map((surface): Row => { | |
| 756 | + const known = "slug" in surface ? surface : null; | |
| 757 | + const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind]; | |
| 758 | + const title = withSurface(pretty, word); | |
| 759 | + // Product identity, not just domain|kind: two same-kind products can | |
| 760 | + // share a domain (Google Photos Library and Picker). | |
| 761 | + const rowKey = `${entry.domain}|${surface.kind}|${known?.slug ?? pretty}`; | |
| 762 | + const quickAddedSlug = quickAddedSlugs.get(rowKey); | |
| 763 | + // Same candidate set as `added`: namespaces come from the registry | |
| 764 | + // slug (quick add), the domain, or the display name (classic add | |
| 765 | + // pages), depending on how the integration got here. | |
| 766 | + const viewSlug = | |
| 767 | + quickAddedSlug ?? installedSlugFor(surface.kind, known?.slug, entry.domain, title); | |
| 768 | + // Slug in the key: one domain can carry many product surfaces of the | |
| 769 | + // same kind (Microsoft Graph's workloads all live on one domain). | |
| 770 | + return { | |
| 771 | + key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`, | |
| 772 | + testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`, | |
| 773 | + title, | |
| 774 | + kindKey: surface.kind, | |
| 775 | + domain: entry.domain, | |
| 776 | + ...(description ? { description } : {}), | |
| 777 | + iconUrl: | |
| 778 | + (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10), | |
| 779 | + onSelect: () => | |
| 780 | + void pickCatalogEntry(entry, surface.kind, title, rowKey, known ?? undefined), | |
| 781 | + added: | |
| 782 | + quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug, entry.domain, title), | |
| 783 | + ...(quickAddedSlug !== undefined ? { freshlyAdded: true } : {}), | |
| 784 | + ...(viewSlug ? { viewSlug } : {}), | |
| 785 | + busy: resolvingDomain === entry.domain || quickAddingKeys.has(rowKey), | |
| 786 | + ...(rowError?.key === rowKey ? { error: rowError.message } : {}), | |
| 787 | + }; | |
| 788 | + }); | |
| 789 | + }); | |
| 790 | + // A registry row for something a preset already offers is a worse copy of | |
| 791 | + // it: same service, no auth template, no health check. Matched on | |
| 792 | + // productKey — normalized name plus kind — because neither titles nor | |
| 793 | + // domains line up reliably: the Gmail preset's icon is Google's (so a | |
| 794 | + // domain comparison let a second "Gmail API" through), and the GitHub | |
| 795 | + // preset says "GitHub REST" where the registry row says "GitHub API". | |
| 796 | + const presetKeys = new Set(presetRows.map((row) => productKey(row.title, row.kindKey))); | |
| 797 | + rows = rows.filter((row) => !presetKeys.has(productKey(row.title, row.kindKey))); | |
| 798 | + if (text.length === 0) return rows; | |
| 799 | + // A name match beats a mention in the blurb: searching "gmail" should not | |
| 800 | + // rank a CRM that merely describes itself as living inside Gmail above | |
| 801 | + // Gmail. Stable within each group, so the registry's own order survives. | |
| 802 | + const isNamed = (row: Row) => `${row.title} ${row.domain ?? ""}`.toLowerCase().includes(text); | |
| 803 | + return [...rows.filter(isNamed), ...rows.filter((row) => !isNamed(row))]; | |
| 804 | + }, [ | |
| 805 | + catalogEntries, | |
| 806 | + isAdded, | |
| 807 | + installedSlugFor, | |
| 808 | + quickAddedSlugs, | |
| 809 | + quickAddingKeys, | |
| 810 | + resolvingDomain, | |
| 811 | + pickCatalogEntry, | |
| 812 | + text, | |
| 813 | + presetRows, | |
| 814 | + rowError, | |
| 815 | + ]); | |
| 816 | + | |
| 817 | + // Local-process cards hold no rank of their own, so each slots in where its | |
| 818 | + // title falls alphabetically among the surrounding rows — pinning them to | |
| 819 | + // the top of a popularity-ranked list gave Chrome DevTools pride of place | |
| 820 | + // over everything. They also WAIT for the catalog: painting a lone Chrome | |
| 821 | + // DevTools card while the registry loads reads as a one-item catalog, then | |
| 822 | + // reflows. When the registry is unreachable they render anyway, so the page | |
| 823 | + // degrades to the cards that still work. | |
| 824 | + const results = useMemo(() => { | |
| 825 | + if (catalog.loading && catalogRows.length === 0 && !catalog.failed) return catalogRows; | |
| 826 | + const merged = [...catalogRows]; | |
| 827 | + for (const preset of presetRows) { | |
| 828 | + const at = merged.findIndex( | |
| 829 | + (row) => row.title.localeCompare(preset.title, undefined, { sensitivity: "base" }) > 0, | |
| 830 | + ); | |
| 831 | + merged.splice(at === -1 ? merged.length : at, 0, preset); | |
| 832 | + } | |
| 833 | + // What you already have leads the list — those cards are the ones with a | |
| 834 | + // state worth seeing (View). Stable within each half, and fresh | |
| 835 | + // quick-adds hold their place until the next visit. | |
| 836 | + const floats = (row: Row) => row.added && row.freshlyAdded !== true; | |
| 837 | + // Installed integrations whose registry rows are NOT in the loaded page | |
| 838 | + // still belong at the front — "added first" is about the user's catalog, | |
| 839 | + // not about which sixty rows the registry ranked highest. Synthesized | |
| 840 | + // from the integration record itself; a loaded row that matched an | |
| 841 | + // installed integration claims its slug, so nothing appears twice. | |
| 842 | + const claimed = new Set(merged.flatMap((row) => (row.viewSlug ? [row.viewSlug] : []))); | |
| 843 | + const installedList: readonly Integration[] = AsyncResult.isSuccess(installed) | |
| 844 | + ? installed.value | |
| 845 | + : []; | |
| 846 | + const installedRows: readonly Row[] = installedList | |
| 847 | + .filter( | |
| 848 | + (row) => | |
| 849 | + row.canRemove && | |
| 850 | + (row.kind === "mcp" || row.kind === "openapi" || row.kind === "graphql") && | |
| 851 | + !claimed.has(String(row.slug)) && | |
| 852 | + (text.length === 0 || | |
| 853 | + row.name.toLowerCase().includes(text) || | |
| 854 | + String(row.slug).includes(text)), | |
| 855 | + ) | |
| 856 | + .map((row): Row => { | |
| 857 | + // The SAME icon cascade the sidebar runs — preset icon by exact | |
| 858 | + // identity, else the logo proxy from the integration's own URL. Two | |
| 859 | + // resolvers for one integration is how the sidebar and the picker | |
| 860 | + // ended up disagreeing about what DeepWiki looks like. | |
| 861 | + const iconUrl = | |
| 862 | + integrationPresetIconUrl( | |
| 863 | + { | |
| 864 | + id: String(row.slug), | |
| 865 | + kind: row.kind, | |
| 866 | + name: row.name, | |
| 867 | + ...(row.displayUrl ? { url: row.displayUrl } : {}), | |
| 868 | + }, | |
| 869 | + integrationPlugins, | |
| 870 | + ) ?? | |
| 871 | + integrationFaviconUrl( | |
| 872 | + row.displayUrl ?? | |
| 873 | + integrationInferredUrl({ id: String(row.slug), name: row.name }) ?? | |
| 874 | + undefined, | |
| 875 | + 10, | |
| 876 | + ); | |
| 877 | + return { | |
| 878 | + key: `installed-${String(row.slug)}`, | |
| 879 | + testId: `installed-${String(row.slug)}`, | |
| 880 | + title: row.name, | |
| 881 | + kindKey: row.kind, | |
| 882 | + ...(row.description && row.description !== row.name | |
| 883 | + ? { description: tidyDescription(row.description) } | |
| 884 | + : {}), | |
| 885 | + ...(iconUrl ? { iconUrl } : {}), | |
| 886 | + onSelect: () => {}, | |
| 887 | + added: true, | |
| 888 | + viewSlug: String(row.slug), | |
| 889 | + busy: false, | |
| 890 | + }; | |
| 891 | + }); | |
| 892 | + return [...installedRows, ...merged.filter(floats), ...merged.filter((row) => !floats(row))]; | |
| 893 | + }, [ | |
| 894 | + presetRows, | |
| 895 | + catalogRows, | |
| 896 | + catalog.loading, | |
| 897 | + catalog.failed, | |
| 898 | + installed, | |
| 899 | + text, | |
| 900 | + integrationPlugins, | |
| 901 | + ]); | |
| 902 | + | |
| 903 | + // Presets are local, so a list that already has them is not empty — show | |
| 904 | + // skeletons only when there is genuinely nothing on screen yet. | |
| 905 | + const loading = catalog.loading && results.length === 0; | |
| 906 | + | |
| 907 | + return ( | |
| 908 | + // PageContainer scrolls the whole page; this page pins the header and | |
| 909 | + // search, and ONLY the results grid scrolls — an endless list makes the | |
| 910 | + // page scrollbar meaningless and drags the search box off screen. | |
| 911 | + <div className="flex min-h-0 flex-1 flex-col" data-slot="page-container"> | |
| 912 | + {quickAddCapablePlugins(integrationPlugins).map((plugin) => ( | |
| 913 | + <QuickAddBridge | |
| 914 | + key={plugin.key} | |
| 915 | + pluginKey={plugin.key} | |
| 916 | + useQuickAdd={plugin.useQuickAdd!} | |
| 917 | + register={registerQuickAdd} | |
| 918 | + /> | |
| 919 | + ))} | |
| 920 | + <div className="mx-auto w-full max-w-4xl shrink-0 px-6 pt-10 lg:px-8 lg:pt-14"> | |
| 921 | + <PageHeader | |
| 922 | + title="Add an integration" | |
| 923 | + description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint." | |
| 924 | + /> | |
| 925 | + | |
| 926 | + <div className="mb-4 flex gap-2"> | |
| 927 | + <div className="relative min-w-0 flex-1"> | |
| 928 | + <SearchIcon | |
| 929 | + className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" | |
| 930 | + aria-hidden | |
| 931 | + /> | |
| 932 | + <Input | |
| 933 | + type="text" | |
| 934 | + value={query} | |
| 935 | + onChange={(event) => { | |
| 936 | + setQuery((event.target as HTMLInputElement).value); | |
| 937 | + setError(null); | |
| 938 | + }} | |
| 939 | + onKeyDown={(event) => { | |
| 940 | + if (event.key === "Enter" && isUrl) void handleDetect(); | |
| 941 | + }} | |
| 942 | + placeholder="Search integrations, or paste a URL…" | |
| 943 | + aria-label="Search integrations, or paste a URL" | |
| 944 | + disabled={detecting} | |
| 945 | + // oxlint-disable-next-line jsx_a11y/no-autofocus -- deliberate: searching is the page's only purpose, and it is reached by an explicit "Add integration" action | |
| 946 | + autoFocus | |
| 947 | + className="h-11 pl-9 text-sm" | |
| 948 | + /> | |
| 949 | + </div> | |
| 950 | + {isUrl ? ( | |
| 951 | + <Button | |
| 952 | + className="h-11 shrink-0" | |
| 953 | + onClick={() => void handleDetect()} | |
| 954 | + disabled={detecting || query.trim().length === 0} | |
| 955 | + loading={detecting} | |
| 956 | + > | |
| 957 | + Add this URL | |
| 958 | + </Button> | |
| 959 | + ) : null} | |
| 960 | + </div> | |
| 961 | + | |
| 962 | + {/* Above the results, not below: an endless list has no reachable | |
| 963 | + bottom, and this is the escape hatch for exactly the person the | |
| 964 | + list is failing. One quiet line; the label carries the action, so | |
| 965 | + the chips stay bare format names. */} | |
| 966 | + <div className="mb-8 flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 967 | + <span className="shrink-0 text-xs text-muted-foreground">Start from scratch:</span> | |
| 968 | + <div className="flex shrink-0 items-center gap-1.5"> | |
| 969 | + {integrationPlugins.map( | |
| 970 | + (plugin: IntegrationPlugin): ReactNode => ( | |
| 971 | + // oxlint-disable-next-line react/forbid-elements -- a chip, not a Button variant | |
| 972 | + <button | |
| 973 | + key={plugin.key} | |
| 974 | + type="button" | |
| 975 | + aria-label={`New ${plugin.label} integration from scratch`} | |
| 976 | + onClick={() => { | |
| 977 | + trackEvent("integration_add_started", { | |
| 978 | + plugin_key: plugin.key, | |
| 979 | + via: "manual", | |
| 980 | + }); | |
| 981 | + void navigate({ | |
| 982 | + to: "/{-$orgSlug}/integrations/add/$pluginKey", | |
| 983 | + params: { pluginKey: plugin.key }, | |
| 984 | + }); | |
| 985 | + }} | |
| 986 | + className="inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" | |
| 987 | + > | |
| 988 | + <PlusIcon className="size-3" aria-hidden /> | |
| 989 | + {plugin.label} | |
| 990 | + </button> | |
| 991 | + ), | |
| 992 | + )} | |
| 993 | + </div> | |
| 994 | + <span className="ml-auto shrink-0 text-xs text-muted-foreground"> | |
| 995 | + Can't find it? Paste its URL above. | |
| 996 | + </span> | |
| 997 | + </div> | |
| 998 | + | |
| 999 | + {error ? ( | |
| 1000 | + <p role="alert" className="mb-4 text-xs text-destructive"> | |
| 1001 | + {error} | |
| 1002 | + </p> | |
| 1003 | + ) : null} | |
| 1004 | + </div> | |
| 1005 | + | |
| 1006 | + <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 1007 | + <div className="mx-auto w-full max-w-4xl px-6 pb-10 lg:px-8"> | |
| 1008 | + {loading ? ( | |
| 1009 | + <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 1010 | + {Array.from({ length: 9 }).map((_, index) => ( | |
| 1011 | + <CardSkeleton key={index} index={index} /> | |
| 1012 | + ))} | |
| 1013 | + </div> | |
| 1014 | + ) : results.length === 0 ? ( | |
| 1015 | + <p className="px-3 py-10 text-center text-sm text-muted-foreground"> | |
| 1016 | + {catalog.failed | |
| 1017 | + ? "Couldn't load the full list right now. You can still paste the URL of an MCP server, OpenAPI spec, or GraphQL endpoint to add it directly." | |
| 1018 | + : "Nothing matches that. Paste the URL of an MCP server, OpenAPI spec, or GraphQL endpoint to add it directly."} | |
| 1019 | + </p> | |
| 1020 | + ) : ( | |
| 1021 | + <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 1022 | + {results.map((row) => ( | |
| 1023 | + <ResultCard key={row.key} row={row} /> | |
| 1024 | + ))} | |
| 1025 | + {catalog.loadingMore | |
| 1026 | + ? Array.from({ length: 3 }, (_, index) => ( | |
| 1027 | + <CardSkeleton key={`more-${index}`} index={index} /> | |
| 1028 | + )) | |
| 1029 | + : null} | |
| 1030 | + {catalog.hasMore ? ( | |
| 1031 | + <div className="col-span-full"> | |
| 1032 | + <LoadMoreSentinel onVisible={catalog.loadMore} /> | |
| 1033 | + </div> | |
| 1034 | + ) : null} | |
| 1035 | + </div> | |
| 1036 | + )} | |
| 1037 | + </div> | |
| 1038 | + </div> | |
| 1039 | + </div> | |
| 1040 | + ); | |
| 1041 | +} | |
| @@ -1,30 +1,15 @@ | ||
| 1 | −import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react"; | |
| 2 | −import { Link, useNavigate } from "@tanstack/react-router"; | |
| 3 | −import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 1 | +import { Suspense, useMemo, type ReactNode } from "react"; | |
| 2 | +import { Link } from "@tanstack/react-router"; | |
| 3 | +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; | |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | −import * as Effect from "effect/Effect"; | |
| 6 | −import * as Exit from "effect/Exit"; | |
| 7 | 5 | import { PlusIcon } from "lucide-react"; |
| 8 | −import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared"; | |
| 9 | −import { | |
| 10 | − useIntegrationPlugins, | |
| 11 | − type IntegrationPlugin, | |
| 12 | − type IntegrationPreset, | |
| 13 | −} from "@executor-js/sdk/client"; | |
| 14 | −import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms"; | |
| 6 | +import type { Integration } from "@executor-js/sdk/shared"; | |
| 7 | +import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/client"; | |
| 8 | +import { integrationsOptimisticAtom } from "../api/atoms"; | |
| 15 | 9 | import { trackEvent } from "../api/analytics"; |
| 16 | 10 | import { McpInstallCard } from "../components/mcp-install-card"; |
| 17 | 11 | import { Button } from "../components/button"; |
| 18 | 12 | import { PageContainer, PageHeader } from "../components/page"; |
| 19 | −import { Badge } from "../components/badge"; | |
| 20 | −import { Input } from "../components/input"; | |
| 21 | −import { | |
| 22 | − Dialog, | |
| 23 | − DialogContent, | |
| 24 | − DialogDescription, | |
| 25 | − DialogHeader, | |
| 26 | − DialogTitle, | |
| 27 | −} from "../components/dialog"; | |
| 28 | 13 | import { |
| 29 | 14 | CardStack, |
| 30 | 15 | CardStackContent, |
| @@ -32,7 +17,6 @@ import { · 1 unmodified line | ||
| 32 | 17 | CardStackEntryActions, |
| 33 | 18 | CardStackEntryContent, |
| 34 | 19 | CardStackEntryDescription, |
| 35 | − CardStackEntryMedia, | |
| 36 | 20 | CardStackEntryTitle, |
| 37 | 21 | CardStackHeader, |
| 38 | 22 | } from "../components/card-stack"; |
| @@ -41,18 +25,7 @@ import { · 2 unmodified lines | ||
| 41 | 25 | integrationInferredUrl, |
| 42 | 26 | integrationPresetIconUrl, |
| 43 | 27 | } from "../components/integration-favicon"; |
| 44 | −import { PresetIcon } from "../components/preset-icon"; | |
| 45 | 28 | import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping"; |
| 46 | −import { | |
| 47 | − availableCatalogKinds, | |
| 48 | − catalogLogoUrl, | |
| 49 | − filterCatalogEntries, | |
| 50 | − presetDomains, | |
| 51 | − resolveConnectTarget, | |
| 52 | − useCatalogSearch, | |
| 53 | − type CatalogKind, | |
| 54 | − type CatalogSearchEntry, | |
| 55 | −} from "../lib/integrations-sh-catalog"; | |
| 56 | 29 | import { IntegrationHealthSummary } from "../components/integration-health-summary"; |
| 57 | 30 | import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; |
| 58 | 31 | import { Skeleton } from "../components/skeleton"; |
| @@ -67,17 +40,6 @@ const KIND_TO_PLUGIN_KEY: Record<string, string> = { · 8 unmodified lines | ||
| 67 | 40 | googleDiscovery: "google", |
| 68 | 41 | }; |
| 69 | 42 | |
| 70 | −const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = { | |
| 71 | − high: 3, | |
| 72 | − medium: 2, | |
| 73 | − low: 1, | |
| 74 | −}; | |
| 75 | − | |
| 76 | −const bestDetection = ( | |
| 77 | − results: readonly IntegrationDetectionResult[], | |
| 78 | −): IntegrationDetectionResult | undefined => | |
| 79 | − [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; | |
| 80 | − | |
| 81 | 43 | // --------------------------------------------------------------------------- |
| 82 | 44 | // Page |
| 83 | 45 | // --------------------------------------------------------------------------- |
| @@ -86,7 +48,6 @@ export function IntegrationsPage() { · 2 unmodified lines | ||
| 86 | 48 | useExecutorDocumentTitle("Integrations"); |
| 87 | 49 | const integrations = useAtomValue(integrationsOptimisticAtom); |
| 88 | 50 | const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); |
| 89 | − const [connectOpen, setConnectOpen] = useState(false); | |
| 90 | 51 | |
| 91 | 52 | return ( |
| 92 | 53 | <PageContainer> |
| @@ -94,16 +55,14 @@ export function IntegrationsPage() { · 1 unmodified line | ||
| 94 | 55 | title="Integrations" |
| 95 | 56 | description="Tool providers available in this workspace." |
| 96 | 57 | actions={ |
| 97 | − <Button | |
| 98 | − onClick={() => { | |
| 99 | − setConnectOpen(true); | |
| 100 | − trackEvent("integration_connect_dialog_opened"); | |
| 101 | − }} | |
| 102 | − size="sm" | |
| 103 | − className="gap-1.5" | |
| 104 | − > | |
| 105 | − <PlusIcon className="size-4" /> | |
| 106 | − Connect | |
| 58 | + <Button asChild size="sm" className="gap-1.5"> | |
| 59 | + <Link | |
| 60 | + to="/{-$orgSlug}/integrations/browse" | |
| 61 | + onClick={() => trackEvent("integration_browse_opened", { via: "header" })} | |
| 62 | + > | |
| 63 | + <PlusIcon className="size-4" /> | |
| 64 | + Add integration | |
| 65 | + </Link> | |
| 107 | 66 | </Button> |
| 108 | 67 | } |
| 109 | 68 | /> |
| @@ -124,14 +83,7 @@ export function IntegrationsPage() { · 14 unmodified lines | ||
| 124 | 83 | ), |
| 125 | 84 | onSuccess: ({ value }) => { |
| 126 | 85 | if (value.length === 0) { |
| 127 | − return ( | |
| 128 | − <EmptyIntegrations | |
| 129 | − onConnect={() => { | |
| 130 | − setConnectOpen(true); | |
| 131 | − trackEvent("integration_connect_dialog_opened"); | |
| 132 | − }} | |
| 133 | − /> | |
| 134 | − ); | |
| 86 | + return <EmptyIntegrations />; | |
| 135 | 87 | } |
| 136 | 88 | |
| 137 | 89 | return ( |
| @@ -142,176 +94,15 @@ export function IntegrationsPage() { · 4 unmodified lines | ||
| 142 | 94 | }, |
| 143 | 95 | }) |
| 144 | 96 | )} |
| 145 | − | |
| 146 | − <ConnectDialog open={connectOpen} onOpenChange={setConnectOpen} /> | |
| 147 | 97 | </PageContainer> |
| 148 | 98 | ); |
| 149 | 99 | } |
| 150 | 100 | |
| 151 | −// --------------------------------------------------------------------------- | |
| 152 | −// Connect dialog — URL detection + manual plugin chooser + presets | |
| 153 | −// --------------------------------------------------------------------------- | |
| 154 | − | |
| 155 | −// Heuristic: the input either looks like a URL (auto-detect) or a free-text | |
| 156 | −// search query (filter the preset list). Anything with a scheme, slash, or | |
| 157 | −// host-with-TLD is treated as a URL; everything else is search. | |
| 158 | −const looksLikeUrl = (raw: string): boolean => { | |
| 159 | − const v = raw.trim(); | |
| 160 | − if (v.length === 0) return false; | |
| 161 | − if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true; | |
| 162 | − if (v.includes("/")) return true; | |
| 163 | − if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true; | |
| 164 | − return false; | |
| 165 | −}; | |
| 166 | − | |
| 167 | −export function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) { | |
| 168 | − const integrationPlugins = useIntegrationPlugins(); | |
| 169 | − const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); | |
| 170 | − const navigate = useNavigate(); | |
| 171 | − | |
| 172 | − const [query, setQuery] = useState(""); | |
| 173 | − const [detecting, setDetecting] = useState(false); | |
| 174 | − const [error, setError] = useState<string | null>(null); | |
| 175 | − | |
| 176 | − const isUrl = looksLikeUrl(query); | |
| 177 | − const presetSearch = isUrl ? "" : query; | |
| 178 | − | |
| 179 | − const closeAndReset = useCallback(() => { | |
| 180 | − setQuery(""); | |
| 181 | − setError(null); | |
| 182 | − setDetecting(false); | |
| 183 | − props.onOpenChange(false); | |
| 184 | − }, [props]); | |
| 185 | − | |
| 186 | − const handleDetect = useCallback(async () => { | |
| 187 | − const trimmed = query.trim(); | |
| 188 | − if (!trimmed) return; | |
| 189 | − setDetecting(true); | |
| 190 | − setError(null); | |
| 191 | − // Detection is read-only — it inspects a URL and returns candidates without | |
| 192 | − // mutating the catalog, so it invalidates nothing. | |
| 193 | − const exit = await doDetect({ | |
| 194 | − payload: { url: trimmed }, | |
| 195 | − reactivityKeys: [], | |
| 196 | − }); | |
| 197 | − if (Exit.isFailure(exit)) { | |
| 198 | − trackEvent("integration_detect_submitted", { success: false }); | |
| 199 | − setError("Detection failed. Try adding an integration manually."); | |
| 200 | − setDetecting(false); | |
| 201 | − return; | |
| 202 | − } | |
| 203 | − const results = exit.value; | |
| 204 | − if (results.length === 0) { | |
| 205 | − trackEvent("integration_detect_submitted", { success: false }); | |
| 206 | − setError("Could not detect an integration type from this URL. Try adding manually."); | |
| 207 | − setDetecting(false); | |
| 208 | − return; | |
| 209 | − } | |
| 210 | − const detected = bestDetection(results); | |
| 211 | − if (!detected) { | |
| 212 | − trackEvent("integration_detect_submitted", { success: false }); | |
| 213 | − setError("Could not detect an integration type from this URL. Try adding manually."); | |
| 214 | − setDetecting(false); | |
| 215 | − return; | |
| 216 | − } | |
| 217 | − trackEvent("integration_detect_submitted", { | |
| 218 | − success: true, | |
| 219 | − detected_kind: detected.kind, | |
| 220 | − confidence: detected.confidence, | |
| 221 | − }); | |
| 222 | − const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind] ?? detected.kind; | |
| 223 | − if (integrationPlugins.some((p) => p.key === pluginKey)) { | |
| 224 | − trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" }); | |
| 225 | − closeAndReset(); | |
| 226 | − void navigate({ | |
| 227 | − to: "/{-$orgSlug}/integrations/add/$pluginKey", | |
| 228 | − params: { pluginKey }, | |
| 229 | − search: { url: trimmed, namespace: detected.slug }, | |
| 230 | − }); | |
| 231 | − } else { | |
| 232 | − setError(`Detected integration type "${detected.kind}" but no plugin is available for it.`); | |
| 233 | − setDetecting(false); | |
| 234 | − } | |
| 235 | − }, [query, doDetect, navigate, integrationPlugins, closeAndReset]); | |
| 236 | − | |
| 237 | − return ( | |
| 238 | − <Dialog | |
| 239 | − open={props.open} | |
| 240 | − onOpenChange={(open) => { | |
| 241 | − if (!open) closeAndReset(); | |
| 242 | − else props.onOpenChange(open); | |
| 243 | − }} | |
| 244 | − > | |
| 245 | − <DialogContent className="sm:max-w-[560px]"> | |
| 246 | − <DialogHeader> | |
| 247 | − <DialogTitle>Connect an integration</DialogTitle> | |
| 248 | − <DialogDescription> | |
| 249 | − Search the preset library, or paste a URL to auto-detect. | |
| 250 | − </DialogDescription> | |
| 251 | − </DialogHeader> | |
| 252 | − | |
| 253 | − <div className="flex min-w-0 flex-col gap-5"> | |
| 254 | − <div className="flex flex-col gap-2"> | |
| 255 | − <div className="flex gap-2"> | |
| 256 | − <Input | |
| 257 | − type="text" | |
| 258 | − value={query} | |
| 259 | − onChange={(e) => { | |
| 260 | − setQuery((e.target as HTMLInputElement).value); | |
| 261 | − setError(null); | |
| 262 | − }} | |
| 263 | − onKeyDown={(e) => { | |
| 264 | − if (e.key === "Enter" && isUrl) void handleDetect(); | |
| 265 | − }} | |
| 266 | − placeholder="Search or paste a URL…" | |
| 267 | − disabled={detecting} | |
| 268 | − className="flex-1" | |
| 269 | − /> | |
| 270 | − {isUrl && ( | |
| 271 | − <Button onClick={() => void handleDetect()} disabled={detecting || !query.trim()}> | |
| 272 | − {detecting ? "Detecting..." : "Detect"} | |
| 273 | − </Button> | |
| 274 | − )} | |
| 275 | − </div> | |
| 276 | − {error && <p className="text-xs text-destructive">{error}</p>} | |
| 277 | − </div> | |
| 278 | − | |
| 279 | − <div className="flex flex-col gap-2"> | |
| 280 | − <p className="text-xs font-medium text-foreground/80">Or add manually</p> | |
| 281 | − <div className="flex flex-wrap gap-2"> | |
| 282 | − {integrationPlugins.map((p) => ( | |
| 283 | − <Link | |
| 284 | − key={p.key} | |
| 285 | − to="/{-$orgSlug}/integrations/add/$pluginKey" | |
| 286 | − params={{ pluginKey: p.key }} | |
| 287 | − onClick={() => { | |
| 288 | − trackEvent("integration_add_started", { plugin_key: p.key, via: "manual" }); | |
| 289 | − closeAndReset(); | |
| 290 | − }} | |
| 291 | − className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted" | |
| 292 | − > | |
| 293 | − {p.label} | |
| 294 | − </Link> | |
| 295 | − ))} | |
| 296 | − </div> | |
| 297 | − </div> | |
| 298 | − | |
| 299 | − <PresetGrid | |
| 300 | − plugins={integrationPlugins} | |
| 301 | − onPick={closeAndReset} | |
| 302 | − searchQuery={presetSearch} | |
| 303 | − /> | |
| 304 | − </div> | |
| 305 | − </DialogContent> | |
| 306 | − </Dialog> | |
| 307 | − ); | |
| 308 | −} | |
| 309 | − | |
| 310 | 101 | // --------------------------------------------------------------------------- |
| 311 | 102 | // Empty state |
| 312 | 103 | // --------------------------------------------------------------------------- |
| 313 | 104 | |
| 314 | −function EmptyIntegrations(props: { onConnect: () => void }) { | |
| 105 | +function EmptyIntegrations() { | |
| 315 | 106 | return ( |
| 316 | 107 | <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16"> |
| 317 | 108 | <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground"> |
| @@ -321,230 +112,19 @@ function EmptyIntegrations(props: { onConnect: () => void }) { · 3 unmodified lines | ||
| 321 | 112 | <p className="mb-5 text-[13px] text-muted-foreground/60"> |
| 322 | 113 | Connect an integration to start curating tools. |
| 323 | 114 | </p> |
| 324 | − <Button onClick={props.onConnect} size="sm" className="gap-1.5"> | |
| 325 | − <PlusIcon className="size-4" /> | |
| 326 | − Connect an integration | |
| 115 | + <Button asChild size="sm" className="gap-1.5"> | |
| 116 | + <Link | |
| 117 | + to="/{-$orgSlug}/integrations/browse" | |
| 118 | + onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })} | |
| 119 | + > | |
| 120 | + <PlusIcon className="size-4" /> | |
| 121 | + Add an integration | |
| 122 | + </Link> | |
| 327 | 123 | </Button> |
| 328 | 124 | </div> |
| 329 | 125 | ); |
| 330 | 126 | } |
| 331 | 127 | |
| 332 | −// --------------------------------------------------------------------------- | |
| 333 | −// Preset grid (for inside the Connect dialog) | |
| 334 | −// --------------------------------------------------------------------------- | |
| 335 | − | |
| 336 | −type PresetEntry = { | |
| 337 | − preset: IntegrationPreset; | |
| 338 | − pluginKey: string; | |
| 339 | − pluginLabel: string; | |
| 340 | −}; | |
| 341 | − | |
| 342 | −const CATALOG_KIND_LABEL: Record<CatalogKind, string> = { | |
| 343 | − mcp: "MCP", | |
| 344 | − openapi: "OpenAPI", | |
| 345 | − graphql: "GraphQL", | |
| 346 | −}; | |
| 347 | − | |
| 348 | −function PresetGrid(props: { | |
| 349 | − plugins: readonly IntegrationPlugin[]; | |
| 350 | − onPick: () => void; | |
| 351 | − /** Controlled filter query forwarded from the dialog's unified | |
| 352 | − * search/URL input. Empty string disables filtering. */ | |
| 353 | − searchQuery?: string; | |
| 354 | −}) { | |
| 355 | − const navigate = useNavigate(); | |
| 356 | − const allPresets = useMemo(() => { | |
| 357 | − const entries: PresetEntry[] = []; | |
| 358 | − for (const plugin of props.plugins) { | |
| 359 | − for (const preset of plugin.presets ?? []) { | |
| 360 | − entries.push({ | |
| 361 | − preset, | |
| 362 | − pluginKey: plugin.key, | |
| 363 | − pluginLabel: plugin.label, | |
| 364 | − }); | |
| 365 | − } | |
| 366 | − } | |
| 367 | − return entries; | |
| 368 | − }, [props.plugins]); | |
| 369 | − | |
| 370 | − const query = (props.searchQuery ?? "").trim(); | |
| 371 | − | |
| 372 | − const filtered = useMemo(() => { | |
| 373 | − const q = query.toLowerCase(); | |
| 374 | − if (q.length === 0) return allPresets; | |
| 375 | − return allPresets.filter(({ preset, pluginLabel }) => { | |
| 376 | − const corpus = | |
| 377 | − `${preset.name} ${preset.summary ?? ""} ${preset.family ?? ""} ${preset.specFormat ?? ""} ${pluginLabel}`.toLowerCase(); | |
| 378 | − return corpus.includes(q); | |
| 379 | − }); | |
| 380 | − }, [allPresets, query]); | |
| 381 | − | |
| 382 | − // Long-tail search over the public integrations.sh registry, shown under the | |
| 383 | − // curated presets. Domains a preset already covers stay preset-only. | |
| 384 | − const catalog = useCatalogSearch(query); | |
| 385 | − const excludeDomains = useMemo(() => presetDomains(props.plugins), [props.plugins]); | |
| 386 | − const availableKinds = useMemo(() => availableCatalogKinds(props.plugins), [props.plugins]); | |
| 387 | − const catalogEntries = useMemo( | |
| 388 | − () => filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds }), | |
| 389 | − [catalog.entries, excludeDomains, availableKinds], | |
| 390 | − ); | |
| 391 | − const [resolvingDomain, setResolvingDomain] = useState<string | null>(null); | |
| 392 | − const [catalogError, setCatalogError] = useState<string | null>(null); | |
| 393 | − | |
| 394 | − const pickCatalogEntry = useCallback( | |
| 395 | − async (entry: CatalogSearchEntry) => { | |
| 396 | − if (resolvingDomain !== null) return; | |
| 397 | − setResolvingDomain(entry.domain); | |
| 398 | − setCatalogError(null); | |
| 399 | − const exit = await Effect.runPromiseExit(resolveConnectTarget(entry.domain, entry.kinds)); | |
| 400 | − setResolvingDomain(null); | |
| 401 | − if (Exit.isFailure(exit)) { | |
| 402 | − setCatalogError( | |
| 403 | − `Couldn't load connect details for ${entry.domain}. Paste a URL above instead.`, | |
| 404 | − ); | |
| 405 | − return; | |
| 406 | − } | |
| 407 | − const target = exit.value; | |
| 408 | − if (!target) { | |
| 409 | − setCatalogError( | |
| 410 | − `No connectable endpoint is on record for ${entry.domain}. Paste a URL above to detect one.`, | |
| 411 | − ); | |
| 412 | − return; | |
| 413 | − } | |
| 414 | − trackEvent("integration_add_started", { | |
| 415 | − plugin_key: target.kind, | |
| 416 | − via: "catalog", | |
| 417 | − catalog_domain: entry.domain, | |
| 418 | − }); | |
| 419 | − props.onPick(); | |
| 420 | − void navigate({ | |
| 421 | − to: "/{-$orgSlug}/integrations/add/$pluginKey", | |
| 422 | − params: { pluginKey: target.kind }, | |
| 423 | − search: { url: target.url, ...(target.slug ? { namespace: target.slug } : {}) }, | |
| 424 | − }); | |
| 425 | − }, | |
| 426 | − [navigate, props, resolvingDomain], | |
| 427 | − ); | |
| 428 | − | |
| 429 | − const showCatalogSection = query.length > 0 && (catalogEntries.length > 0 || catalog.loading); | |
| 430 | − | |
| 431 | − if (allPresets.length === 0) return null; | |
| 432 | − | |
| 433 | − return ( | |
| 434 | − <div className="flex min-w-0 flex-col gap-2"> | |
| 435 | − <p className="text-xs font-medium text-foreground/80">Popular integrations</p> | |
| 436 | − <CardStack className="min-w-0"> | |
| 437 | − {/* Fixed height keeps the dialog stable as the user filters; the | |
| 438 | − * inner area scrolls when the list overflows and shows an empty | |
| 439 | − * state when no presets match. */} | |
| 440 | − <CardStackContent className="h-64 overflow-y-auto"> | |
| 441 | − {filtered.length === 0 && !showCatalogSection ? ( | |
| 442 | − <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-6 text-center"> | |
| 443 | − <p className="text-sm text-muted-foreground">No matching integrations</p> | |
| 444 | − <p className="text-xs text-muted-foreground/70"> | |
| 445 | − Paste a URL above to auto-detect, or pick an integration type manually. | |
| 446 | − </p> | |
| 447 | − </div> | |
| 448 | − ) : ( | |
| 449 | − filtered.map(({ preset, pluginKey, pluginLabel }) => { | |
| 450 | − const search: Record<string, string> = { preset: preset.id }; | |
| 451 | − if (preset.url) search.url = preset.url; | |
| 452 | − return ( | |
| 453 | − <CardStackEntry key={`${pluginKey}-${preset.id}`} asChild> | |
| 454 | − <Link | |
| 455 | − to="/{-$orgSlug}/integrations/add/$pluginKey" | |
| 456 | − params={{ pluginKey }} | |
| 457 | − search={search} | |
| 458 | − onClick={() => { | |
| 459 | − trackEvent("integration_add_started", { | |
| 460 | − plugin_key: pluginKey, | |
| 461 | − via: "preset", | |
| 462 | − preset_id: preset.id, | |
| 463 | − }); | |
| 464 | − props.onPick(); | |
| 465 | − }} | |
| 466 | − > | |
| 467 | − <CardStackEntryMedia> | |
| 468 | − <PresetIcon | |
| 469 | − {...(preset.icon ? { icon: preset.icon } : {})} | |
| 470 | − {...(preset.fallbackIcon ? { fallbackSrc: preset.fallbackIcon } : {})} | |
| 471 | − className="size-5 object-contain" | |
| 472 | − fallback={ | |
| 473 | − <svg viewBox="0 0 16 16" className="size-3.5" fill="none"> | |
| 474 | − <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" /> | |
| 475 | − </svg> | |
| 476 | − } | |
| 477 | − /> | |
| 478 | − </CardStackEntryMedia> | |
| 479 | − <CardStackEntryContent> | |
| 480 | − <CardStackEntryTitle>{preset.name}</CardStackEntryTitle> | |
| 481 | − <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription> | |
| 482 | − </CardStackEntryContent> | |
| 483 | − <CardStackEntryActions> | |
| 484 | − <Badge variant="secondary">{pluginLabel}</Badge> | |
| 485 | − </CardStackEntryActions> | |
| 486 | − </Link> | |
| 487 | − </CardStackEntry> | |
| 488 | − ); | |
| 489 | − }) | |
| 490 | − )} | |
| 491 | − {showCatalogSection && ( | |
| 492 | − <> | |
| 493 | − {catalogEntries.map((entry) => ( | |
| 494 | − <CardStackEntry key={`catalog-${entry.domain}`} asChild> | |
| 495 | − <Button | |
| 496 | − type="button" | |
| 497 | − variant="ghost" | |
| 498 | − onClick={() => void pickCatalogEntry(entry)} | |
| 499 | − className="h-auto w-full justify-start rounded-none text-left font-normal whitespace-normal hover:bg-accent/40 dark:hover:bg-accent/40" | |
| 500 | − > | |
| 501 | − <CardStackEntryMedia> | |
| 502 | − <img | |
| 503 | − src={catalogLogoUrl(entry.domain, 10)} | |
| 504 | − alt="" | |
| 505 | − className="size-5 object-contain" | |
| 506 | − loading="lazy" | |
| 507 | − /> | |
| 508 | − </CardStackEntryMedia> | |
| 509 | − <CardStackEntryContent> | |
| 510 | − <CardStackEntryTitle>{entry.domain}</CardStackEntryTitle> | |
| 511 | − <CardStackEntryDescription>{entry.description}</CardStackEntryDescription> | |
| 512 | − </CardStackEntryContent> | |
| 513 | − <CardStackEntryActions> | |
| 514 | − {resolvingDomain === entry.domain ? ( | |
| 515 | − <span className="text-xs text-muted-foreground">Resolving…</span> | |
| 516 | − ) : ( | |
| 517 | − entry.kinds.map((kind) => ( | |
| 518 | − <Badge key={kind} variant="secondary"> | |
| 519 | − {CATALOG_KIND_LABEL[kind]} | |
| 520 | − </Badge> | |
| 521 | − )) | |
| 522 | − )} | |
| 523 | − </CardStackEntryActions> | |
| 524 | − </Button> | |
| 525 | − </CardStackEntry> | |
| 526 | − ))} | |
| 527 | − {catalog.loading && | |
| 528 | − catalogEntries.length === 0 && | |
| 529 | − Array.from({ length: 3 }).map((_, i) => ( | |
| 530 | − <div key={`catalog-skeleton-${i}`} className="flex items-center gap-3 px-4 py-3"> | |
| 531 | − <Skeleton className="size-5 shrink-0 rounded-md" /> | |
| 532 | − <div className="flex min-w-0 flex-1 flex-col gap-1.5"> | |
| 533 | − <Skeleton className="h-3.5" style={{ width: `${35 + ((i * 13) % 25)}%` }} /> | |
| 534 | − <Skeleton className="h-3" style={{ width: `${55 + ((i * 9) % 20)}%` }} /> | |
| 535 | − </div> | |
| 536 | − <Skeleton className="h-5 w-14 rounded-full" /> | |
| 537 | − </div> | |
| 538 | − ))} | |
| 539 | − </> | |
| 540 | − )} | |
| 541 | − </CardStackContent> | |
| 542 | − </CardStack> | |
| 543 | − {catalogError && <p className="text-xs text-destructive">{catalogError}</p>} | |
| 544 | − </div> | |
| 545 | − ); | |
| 546 | −} | |
| 547 | − | |
| 548 | 128 | // --------------------------------------------------------------------------- |
| 549 | 129 | // Integration grid — flat list of catalog integrations, click-through to detail |
| 550 | 130 | // --------------------------------------------------------------------------- |
| @@ -8,6 +8,14 @@ const SearchParams = Schema.toStandardSchemaV1( · 7 unmodified lines | ||
| 8 | 8 | url: Schema.optional(Schema.String), |
| 9 | 9 | preset: Schema.optional(Schema.String), |
| 10 | 10 | namespace: Schema.optional(Schema.String), |
| 11 | + // Registry-declared credential placement, e.g. "Authorization: {api_key}". | |
| 12 | + // Carried for surfaces whose connect target can't describe its own auth | |
| 13 | + // (GraphQL endpoints have no spec document). | |
| 14 | + authHeader: Schema.optional(Schema.String), | |
| 15 | + authNote: Schema.optional(Schema.String), | |
| 16 | + authKind: Schema.optional(Schema.String), | |
| 17 | + // JSON-encoded RFC 6902 patch the registry says to apply to the spec. | |
| 18 | + specOverrides: Schema.optional(Schema.String), | |
| 11 | 19 | }), |
| 12 | 20 | ); |
| 13 | 21 | |
| @@ -15,9 +23,19 @@ export const Route = createFileRoute("/{-$orgSlug}/integrations/add/$pluginKey") · 1 unmodified line | ||
| 15 | 23 | validateSearch: SearchParams, |
| 16 | 24 | component: () => { |
| 17 | 25 | const { pluginKey } = Route.useParams(); |
| 18 | − const { url, preset, namespace } = Route.useSearch(); | |
| 26 | + const { url, preset, namespace, authHeader, authNote, authKind, specOverrides } = | |
| 27 | + Route.useSearch(); | |
| 19 | 28 | return ( |
| 20 | − <AddIntegrationPage pluginKey={pluginKey} url={url} preset={preset} namespace={namespace} /> | |
| 29 | + <AddIntegrationPage | |
| 30 | + pluginKey={pluginKey} | |
| 31 | + url={url} | |
| 32 | + preset={preset} | |
| 33 | + namespace={namespace} | |
| 34 | + authHeader={authHeader} | |
| 35 | + authNote={authNote} | |
| 36 | + authKind={authKind} | |
| 37 | + specOverrides={specOverrides} | |
| 38 | + /> | |
| 21 | 39 | ); |
| 22 | 40 | }, |
| 23 | 41 | }); |
| @@ -0,0 +1,7 @@ | ||
| 1 | +import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | + | |
| 3 | +import { IntegrationBrowsePage } from "../pages/integration-browse"; | |
| 4 | + | |
| 5 | +export const Route = createFileRoute("/{-$orgSlug}/integrations/browse")({ | |
| 6 | + component: IntegrationBrowsePage, | |
| 7 | +}); | |
| @@ -18,6 +18,7 @@ import { Route as DotPoliciesRouteImport } from './policies' · 17 unmodified lines | ||
| 18 | 18 | import { Route as DotArtifactsRouteImport } from './artifacts' |
| 19 | 19 | import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' |
| 20 | 20 | import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' |
| 21 | +import { Route as DotIntegrationsDotbrowseRouteImport } from './integrations.browse' | |
| 21 | 22 | import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' |
| 22 | 23 | import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug' |
| 23 | 24 | import { Route as DotArtifactsDotartifactIdRouteImport } from './artifacts.$artifactId' |
| @@ -70,6 +71,12 @@ const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({ · 46 unmodified lines | ||
| 70 | 71 | path: '/{-$orgSlug}/resume/$executionId', |
| 71 | 72 | getParentRoute: () => rootRouteImport, |
| 72 | 73 | } as any) |
| 74 | +const DotIntegrationsDotbrowseRoute = | |
| 75 | + DotIntegrationsDotbrowseRouteImport.update({ | |
| 76 | + id: '/{-$orgSlug}/integrations/browse', | |
| 77 | + path: '/{-$orgSlug}/integrations/browse', | |
| 78 | + getParentRoute: () => rootRouteImport, | |
| 79 | + } as any) | |
| 73 | 80 | const DotIntegrationsDotnamespaceRoute = |
| 74 | 81 | DotIntegrationsDotnamespaceRouteImport.update({ |
| 75 | 82 | id: '/{-$orgSlug}/integrations/$namespace', |
| @@ -112,6 +119,7 @@ export interface FileRoutesByFullPath { · 36 unmodified lines | ||
| 112 | 119 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute |
| 113 | 120 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute |
| 114 | 121 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 122 | + '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute | |
| 115 | 123 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 116 | 124 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute |
| 117 | 125 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute |
| @@ -128,6 +136,7 @@ export interface FileRoutesByTo { · 10 unmodified lines | ||
| 128 | 136 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute |
| 129 | 137 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute |
| 130 | 138 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 139 | + '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute | |
| 131 | 140 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 132 | 141 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute |
| 133 | 142 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute |
| @@ -145,6 +154,7 @@ export interface FileRoutesById { · 11 unmodified lines | ||
| 145 | 154 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute |
| 146 | 155 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute |
| 147 | 156 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 157 | + '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute | |
| 148 | 158 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 149 | 159 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute |
| 150 | 160 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute |
| @@ -163,6 +173,7 @@ export interface FileRouteTypes { · 12 unmodified lines | ||
| 163 | 173 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 164 | 174 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 165 | 175 | | '/{-$orgSlug}/integrations/$namespace' |
| 176 | + | '/{-$orgSlug}/integrations/browse' | |
| 166 | 177 | | '/{-$orgSlug}/resume/$executionId' |
| 167 | 178 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 168 | 179 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -179,6 +190,7 @@ export interface FileRouteTypes { · 10 unmodified lines | ||
| 179 | 190 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 180 | 191 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 181 | 192 | | '/{-$orgSlug}/integrations/$namespace' |
| 193 | + | '/{-$orgSlug}/integrations/browse' | |
| 182 | 194 | | '/{-$orgSlug}/resume/$executionId' |
| 183 | 195 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 184 | 196 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -195,6 +207,7 @@ export interface FileRouteTypes { · 10 unmodified lines | ||
| 195 | 207 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 196 | 208 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 197 | 209 | | '/{-$orgSlug}/integrations/$namespace' |
| 210 | + | '/{-$orgSlug}/integrations/browse' | |
| 198 | 211 | | '/{-$orgSlug}/resume/$executionId' |
| 199 | 212 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 200 | 213 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -211,6 +224,7 @@ export interface RootRouteChildren { · 10 unmodified lines | ||
| 211 | 224 | DotIndexRoute: typeof DotIndexRoute |
| 212 | 225 | DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute |
| 213 | 226 | DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute |
| 227 | + DotIntegrationsDotbrowseRoute: typeof DotIntegrationsDotbrowseRoute | |
| 214 | 228 | DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute |
| 215 | 229 | DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute |
| 216 | 230 | DotPluginsDotpluginIdDotsplatRoute: typeof DotPluginsDotpluginIdDotsplatRoute |
| @@ -281,6 +295,13 @@ declare module '@tanstack/react-router' { · 64 unmodified lines | ||
| 281 | 295 | preLoaderRoute: typeof DotResumeDotexecutionIdRouteImport |
| 282 | 296 | parentRoute: typeof rootRouteImport |
| 283 | 297 | } |
| 298 | + '/{-$orgSlug}/integrations/browse': { | |
| 299 | + id: '/{-$orgSlug}/integrations/browse' | |
| 300 | + path: '/{-$orgSlug}/integrations/browse' | |
| 301 | + fullPath: '/{-$orgSlug}/integrations/browse' | |
| 302 | + preLoaderRoute: typeof DotIntegrationsDotbrowseRouteImport | |
| 303 | + parentRoute: typeof rootRouteImport | |
| 304 | + } | |
| 284 | 305 | '/{-$orgSlug}/integrations/$namespace': { |
| 285 | 306 | id: '/{-$orgSlug}/integrations/$namespace' |
| 286 | 307 | path: '/{-$orgSlug}/integrations/$namespace' |
| @@ -353,6 +374,7 @@ const rootRouteChildren: RootRouteChildren = { · 66 unmodified lines | ||
| 353 | 374 | DotIndexRoute: DotIndexRoute, |
| 354 | 375 | DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute, |
| 355 | 376 | DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute, |
| 377 | + DotIntegrationsDotbrowseRoute: DotIntegrationsDotbrowseRoute, | |
| 356 | 378 | DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute, |
| 357 | 379 | DotIntegrationsDotaddDotpluginKeyRoute: |
| 358 | 380 | DotIntegrationsDotaddDotpluginKeyRoute, |
| @@ -0,0 +1,515 @@ | ||
| 1 | +# Kill the plugin system — in-place migration plan | |
| 2 | + | |
| 3 | +Decision (Rhys, 2026-07-27): abandon the big-bang rework | |
| 4 | +(`UsefulSoftwareCo/executor-rework`, cloned read-only at | |
| 5 | +`.reference/executor-rework`); fix this repo in place over a sequence of PRs, | |
| 6 | +each keeping format/lint/typecheck/test/e2e green. Priority order: **data | |
| 7 | +models first**, then the plugin kill, then the concept-package (apps) model, | |
| 8 | +then the minimal SDK / platform split. | |
| 9 | + | |
| 10 | +RESEQUENCED (Rhys, 2026-07-28): **Phase 3 (apps/concept-package model) now | |
| 11 | +lands BEFORE Phase 2 (plugin kill), side by side with the protocol plugins.** | |
| 12 | +Rationale: the new model's tables are greenfield and additive — landing it | |
| 13 | +first means the legacy apps tables never need an in-place migration; the whole | |
| 14 | +model swaps underneath while openapi/mcp/graphql keep running as plugins. | |
| 15 | +Structurally sound because (a) the kernel + MCP host are already plugin-free, | |
| 16 | +so the new model's runtime never touched the registry, and (b) the apps plugin | |
| 17 | +already exercises every extension point, so the new model ships as the apps | |
| 18 | +plugin's new internals wearing the same costume. When 2.3 later converts the | |
| 19 | +four kinds, apps converts as a stable, proven model instead of mid-rewrite. | |
| 20 | +Phase 0 and 2.1 remain freely parallel — they don't touch apps. | |
| 21 | + | |
| 22 | +Hard line: side-by-side means new-apps-model alongside the _protocol plugins_, | |
| 23 | +never two app models with dual read paths inside the apps kind. Existing app | |
| 24 | +sources get a one-off migration to published-source form (enumerated | |
| 25 | +invariants, explicit approval, no permanent compatibility path). Accepted | |
| 26 | +cost: the new model's HTTP surface temporarily wears plugin-route ceremony | |
| 27 | +(HttpApiGroup + handler layers) that 2.2/2.6 later deletes — wiring, not | |
| 28 | +model. The authoring SDK (`defineApp`/`defineProvider`/`defineTool`) goes | |
| 29 | +public early; its boundaries take Standard Schema (StandardSchemaV1 & | |
| 30 | +StandardJSONSchemaV1), never Effect Schema, and Phase 4's vocabulary rules | |
| 31 | +(opaque owner refs, no "tenant") bind on it from day one. | |
| 32 | + | |
| 33 | +## Diagnosis (verified against the code) | |
| 34 | + | |
| 35 | +**The plugin system is a fiction.** `packages/core/sdk/src/plugin.ts` (~850 | |
| 36 | +lines, ~20 optional hooks) pretends the plugin set is open, but: | |
| 37 | + | |
| 38 | +- `Integration.kind` _is_ the plugin id string; all hot paths dispatch via | |
| 39 | + `runtimes.get(row.plugin_id)` (`executor.ts`). | |
| 40 | +- Core and React hardcode the closed set anyway: `migration-spec.ts` hardcodes | |
| 41 | + plugin ids; `packages/react` has `KIND_TO_PLUGIN_KEY` maps and an | |
| 42 | + `if (pluginId === "toolkits")` route branch. | |
| 43 | +- Nobody ever shipped a fourth protocol plugin, and the two provider plugins | |
| 44 | + (google/microsoft) were already collapsed into openapi (#1366). | |
| 45 | +- Whole packages exist only to support the open set: `plugin-routes.ts` | |
| 46 | + type-union machinery, `@executor-js/config`'s jsonc+jiti loader, | |
| 47 | + `packages/core/vite-plugin`'s virtual client module. The jsonc plugin path is | |
| 48 | + vestigial — no `executor.jsonc` exists anywhere in the repo, no docs mention | |
| 49 | + it. | |
| 50 | +- Toolkits (~880 LoC of pure curation + the single `toolPolicyProvider` slot) | |
| 51 | + and apps (~6.8k custom-tools gateway) are first-party capabilities wearing a | |
| 52 | + plugin costume. Kernel packages and `packages/hosts/mcp` have zero plugin | |
| 53 | + references — the indirection is not load-bearing for the real surfaces. | |
| 54 | + | |
| 55 | +**The data model is connection-first, not user-first** | |
| 56 | +(`packages/core/sdk/src/core-schema.ts`): | |
| 57 | + | |
| 58 | +- No user/subject table. `subject` is an opaque partition-key string smeared | |
| 59 | + across owned tables; a user "exists" only if they have a connection row. | |
| 60 | +- Disconnect is a hard delete (`connectionsRemove`) — a signed-out user is | |
| 61 | + bit-identical to one who never connected. "Which users are signed out" is | |
| 62 | + structurally unanswerable. | |
| 63 | +- Every Executor is bound to one `{tenant, subject}` via `owner-policy.ts`; | |
| 64 | + there is no cross-subject read path, so no admin view. | |
| 65 | +- Toolkit state lives in generic `plugin_storage` KV, not core tables. | |
| 66 | +- Cloud's real identity tables (WorkOS accounts/organizations) have no join to | |
| 67 | + `subject`. | |
| 68 | + | |
| 69 | +**What to salvage from the rework** (its model docs are the valuable artifact; | |
| 70 | +its implementation ported only ~10 of 131 legacy e2e scenarios — that's why it | |
| 71 | +failed, and why this plan is incremental): | |
| 72 | + | |
| 73 | +- Apps-as-published-source: `defineApp`/`defineProvider`/`defineTool`/ | |
| 74 | + `defineToolGroup`, immutable content-hashed versions, manifest derived by | |
| 75 | + executing the source, rollback = pointer move. | |
| 76 | +- Generators: OpenAPI/MCP specs generate the _same_ app source a human would | |
| 77 | + write, with a re-verifiable `GeneratorClaim` (first-party vs third-party). | |
| 78 | +- Minimal SDK boundary: `createExecutor`/`createRemoteExecutor`, no Effect | |
| 79 | + crossing the boundary, MCP exposure returns a standard MCP SDK server. | |
| 80 | +- Custody separation: secrets never on the wire; opaque owner refs. | |
| 81 | + | |
| 82 | +## Constraints discovered (do not relearn these) | |
| 83 | + | |
| 84 | +1. **Published packages make this semver-major.** `scripts/publish-packages.ts` | |
| 85 | + publishes `@executor-js/{config,sdk}` and plugins | |
| 86 | + `{example,file-secrets,graphql,keychain,mcp,onepassword,openapi}` — killing | |
| 87 | + `definePlugin` is a breaking release for seven packages. Changesets + | |
| 88 | + deprecation story required; `publish-packages.ts`, `pkg-pr-new.yml`, | |
| 89 | + `check-changelog-stubs.ts`, and `examples/*` all have hardcoded lists. | |
| 90 | +2. **`plugin_id = 'google'` is a live persisted value with no runtime.** The | |
| 91 | + provider-service-split migration skips orgs with missing spec blobs | |
| 92 | + (`sqlite.test.ts` asserts survivors). The `googleDiscovery → "google"` | |
| 93 | + entries in React are compensating, not dangling. The closed SourceType | |
| 94 | + switch must map `"google"`/`"googleDiscovery"` → openapi or degraded rows | |
| 95 | + become hard failures. | |
| 96 | +3. **FumaDB is not versioned in practice.** All hosts pin `version: "1.0.0"`; | |
| 97 | + runtime bring-up is `ensureDrizzleRuntimeSchemaFromTables` (CREATE TABLE IF | |
| 98 | + NOT EXISTS + additive nullable ALTERs). New tables/nullable columns are free | |
| 99 | + on local/self-host/D1. **Cloud is the exception**: hand-edit | |
| 100 | + `apps/cloud/src/db/executor-schema.ts` + drizzle-kit migration + | |
| 101 | + `db:migrate:prod`; `db.schema.test.ts` enforces the mirror, so "forgot | |
| 102 | + cloud" fails tests. | |
| 103 | +4. **93 e2e files import `composePluginApi`** purely to type the client. Keep | |
| 104 | + it as a deprecated argument-ignoring shim returning the static | |
| 105 | + `FullExecutorApi`; sweep the call sites mechanically in one final PR. | |
| 106 | +5. **Keep the `plugin_id` column name** (four tables + blob namespace format | |
| 107 | + `o:<tenant>/<plugin_id>`). Renaming to `source_kind` is a cross-host | |
| 108 | + migration for zero behavior. Retype on the read side only. | |
| 109 | +6. **URL contracts:** `/integrations/add/$pluginKey` is navigated directly by | |
| 110 | + e2e; `/plugins/desktop-settings/` is asserted by the desktop-packaged suite | |
| 111 | + and baked into a tool's returned URL. Keep shapes / add redirects. | |
| 112 | +7. **CI gaps:** PRs run cloud (4 shards) + selfhost e2e only; local/desktop | |
| 113 | + suites gate push-to-main. PRs touching desktop-settings or the local loader | |
| 114 | + need a manual local e2e run pre-merge. | |
| 115 | +8. **Toolkit selection filters _connections_, not just tools** | |
| 116 | + (`connectionsList` goes through the active policy provider). Preserve when | |
| 117 | + moving toolkits to core, or the toolkit e2e suites go red. | |
| 118 | + | |
| 119 | +## Phase 0 — declutter (3 PRs) | |
| 120 | + | |
| 121 | +- **0.1** Delete empty `packages/plugins/{google,microsoft}`; drop the unused | |
| 122 | + `@executor-js/config` dep from openapi/mcp/graphql; move | |
| 123 | + `provider-service-split` to `packages/migrations/` (keep package name — it's | |
| 124 | + imported by three host data-migration registries). In the React maps, keep | |
| 125 | + `google`/`googleDiscovery` → openapi with a comment citing constraint 2. | |
| 126 | +- **0.2** Bake google/microsoft catalogs + spec-format adapters in as openapi | |
| 127 | + defaults; strip the re-threading from all four host configs. Gate: | |
| 128 | + `provider-plugins-ui`, `google-health-checks`, `microsoft-*` scenarios. | |
| 129 | +- **0.3** Move desktop-settings (438-line Electron-IPC client, not a | |
| 130 | + one-liner) to a first-class desktop-only route in `packages/app`; update the | |
| 131 | + static tool's URL and the desktop-packaged e2e selector; delete the plugin. | |
| 132 | + | |
| 133 | +## Phase 1 — data model (4 PRs) — FIRST PRIORITY | |
| 134 | + | |
| 135 | +Design choices (least-invasive, verified feasible): | |
| 136 | + | |
| 137 | +- The new **subject table is `tenant`-scoped, not owner-scoped** — readable by | |
| 138 | + any executor bound to the tenant with **zero policy changes** (the | |
| 139 | + `integration` table already proves the pattern). | |
| 140 | +- Cross-subject connection reads: add `reach: "bound" | "tenant"` to | |
| 141 | + `ExecutorOwnerPolicyContext` — widens `ownerVisibilityCondition` for reads | |
| 142 | + only; `assertOwnerWritable`/`assertOwnerPatch` untouched, so admin reach can | |
| 143 | + never write. Only two construction sites exist (`executor.ts`, | |
| 144 | + `test-config.ts`). | |
| 145 | +- Identity stays with the hosts (WorkOS / Better Auth member lists already | |
| 146 | + exist behind `AccountProvider.listMembers`); the subject table is the missing | |
| 147 | + **join** between host identity and executor connections, not a new identity | |
| 148 | + system. Tolerate `subject = null` (pure-org) and `subject = "local"`. | |
| 149 | + | |
| 150 | +PRs: | |
| 151 | + | |
| 152 | +- **1.1** Add tenant-scoped `subject` table (id, tenant, external_id, | |
| 153 | + created_at, last_seen_at, status) to `coreTables` + cloud mirror + one | |
| 154 | + drizzle migration. No readers yet. | |
| 155 | +- **1.2** Populate: upsert in `makeScopedExecutor` | |
| 156 | + (`packages/core/api/src/server/scoped-executor.ts` — every HTTP request and | |
| 157 | + MCP session on all four hosts passes through it) and at connection-create. | |
| 158 | + Add to `purgeOrganizationData` + its `TENANT_TABLES` test. | |
| 159 | +- **1.3** `reach` mode + `admin.listSubjectsWithConnections`-shaped read on a | |
| 160 | + derived handle. Policy test proving admin reach cannot write. Scope includes | |
| 161 | + (customer ask, owner.com 2026-07-27): per-subject reads ("connections for | |
| 162 | + subject X"), not just the aggregate list, and a privileged org-level API key | |
| 163 | + (keys already carry `{accountId, organizationId}` — | |
| 164 | + `apps/cloud/src/auth/api-keys.ts`) that resolves to the tenant-reach | |
| 165 | + read-only handle so customers can inspect connections programmatically. | |
| 166 | +- **1.4** Connection lifecycle: nullable `status` column | |
| 167 | + (active/disconnected/revoked/expired) plus `status_changed_at`; disconnect | |
| 168 | + writes status instead of deleting. Wide blast radius — status predicate | |
| 169 | + through `findConnectionRow`, `connectionsList`, credential resolution, | |
| 170 | + `toolsList`, org purge; own e2e sweep (`connections-*`, `health-checks*`, | |
| 171 | + `oauth-*`). PRIORITY RAISED (customer, 2026-07-28): the customer's | |
| 172 | + block-startup-until-reconnect flow needs connected-then-disconnected to be | |
| 173 | + distinguishable from never-connected, which hard-delete makes impossible | |
| 174 | + today; expose status + statusChangedAt through the admin API when this | |
| 175 | + lands. | |
| 176 | + | |
| 177 | +Toolkits data does NOT move in this phase (it moves with its reader in 2.4 — | |
| 178 | +moving it earlier means either rewriting the plugin storage layer twice or a | |
| 179 | +forbidden dual read path). | |
| 180 | + | |
| 181 | +### Framing: product view vs platform view (Rhys, 2026-07-27) | |
| 182 | + | |
| 183 | +The two-view model for consumer-facing surfaces, longer-term: | |
| 184 | + | |
| 185 | +- **Product view** — the bound, per-individual executor every request gets | |
| 186 | + today (their connections, their tools). The default; unchanged. | |
| 187 | +- **Platform view** — an operator-level view attached alongside it: read-only, | |
| 188 | + cross-user, explicitly reached for with elevated access (an escape hatch, | |
| 189 | + never a mode product view drifts into). The `reach: "bound" | "tenant"` | |
| 190 | + split in 1.3 is this model's first implementation; 1.5's admin API and the | |
| 191 | + privileged key are its first surface. Later platform-view candidates (usage, | |
| 192 | + policy overview, cross-user health) land on the same handle. | |
| 193 | + | |
| 194 | +Cloud wrinkle: cloud consumers like owner.com don't bring their own owner ids — | |
| 195 | +their users authenticate through our WorkOS, so `subject` is _our_ accountId | |
| 196 | +and the customer correlates on their side. The opaque-owner-ids-in-the- | |
| 197 | +customer's-id-space story fully applies only to embedded/API consumers; for | |
| 198 | +cloud, the platform view (+ `external_id` on the subject table) is what makes | |
| 199 | +the correlation tractable. | |
| 200 | + | |
| 201 | +### Fast path: owner.com admin-visibility ask (ships from Phase 1 alone) | |
| 202 | + | |
| 203 | +The owner.com ask (admin inspects users' connections; API key that can do the | |
| 204 | +same; available-vs-connected icon grid; direct connect links) needs only | |
| 205 | +1.1 → 1.2 → 1.3 plus a thin delivery layer — no dependency on Phase 0 or the | |
| 206 | +plugin kill. Delivery PRs on top of 1.3: | |
| 207 | + | |
| 208 | +- **1.5** Public admin API surface: `GET .../admin/users`, | |
| 209 | + `GET .../admin/users/:externalId/connections` (integration slug + status + | |
| 210 | + last_health only — never item_ids/oauth fields), gated on the privileged key | |
| 211 | + / admin membership. Public paths say `users`, not `subjects`/`tenants` — see | |
| 212 | + "Vocabulary" below. The available-vs-connected diff is computable client-side | |
| 213 | + from this plus the existing integration catalog. | |
| 214 | +- **1.6** Connect deep links: stable | |
| 215 | + `/connect/:integrationSlug`-shaped URL that lands the arriving user in the | |
| 216 | + connect flow for that integration (auth-aware redirect). Uses integration | |
| 217 | + slugs, not plugin routes, so it survives Phase 2.5 unchanged. | |
| 218 | + | |
| 219 | + 1.4 (lifecycle status) upgrades the grid from binary connected/not to | |
| 220 | + expired/disconnected states but is not a blocker for first ship. | |
| 221 | + | |
| 222 | +## Phase 2 — kill the registry (6 PRs) | |
| 223 | + | |
| 224 | +- **2.1** Remove dynamic loading: delete `load-plugins.ts` from | |
| 225 | + `@executor-js/config` (keep the rest — jsonc integration config is a separate | |
| 226 | + feature), drop `loadPluginsFromJsonc` from `apps/local`, delete | |
| 227 | + `packages/core/vite-plugin`, replace `virtual:executor/plugins-client` with a | |
| 228 | + static module in `packages/app` (5 `__root.tsx` importers + 3 ambient | |
| 229 | + `.d.ts`), edit `turbo.json` `dev.dependsOn` + root `prepare`, fix | |
| 230 | + `packages/app/vite.config.ts`'s dangling jsonc path. Breaking changeset. | |
| 231 | +- **2.2** Static route list: `composePluginApi` becomes the deprecated shim | |
| 232 | + (constraint 4); hosts stop passing plugin tuples; delete | |
| 233 | + `apps/{cloud,host-selfhost}/src/plugins.ts` (exist only to hold | |
| 234 | + `PluginExtensionServices` typing). | |
| 235 | +- **2.3** The switch: closed internal `SourceType` interface implemented by | |
| 236 | + openapi/graphql/mcp/**apps (current model, unchanged)** as plain libraries, | |
| 237 | + replacing `runtimes.get`. All four kinds convert together — converting three | |
| 238 | + and leaving apps on the plugin path means carrying dual dispatch; rewriting | |
| 239 | + the apps model in the same PR is what killed the rework. Map | |
| 240 | + `"google"`/`"googleDiscovery"` → openapi. `plugin_id` column name unchanged. | |
| 241 | +- **2.4** Toolkits to core: core tables + one-off migration out of | |
| 242 | + `plugin_storage` (enumerated invariants, no dual path), `toolPolicyProvider` | |
| 243 | + slot → native policy input (one call site, one implementation, one test), | |
| 244 | + ordinary core routes, `activeToolkitSlug` becomes an executor option. | |
| 245 | + Preserve the connection-visibility filter (constraint 8). | |
| 246 | +- **2.5** React: four named kind renderers; move plugin React code (~2.9k LoC, | |
| 247 | + e.g. `AddMcpIntegration.tsx`) into `packages/react` — watch the peer-dep | |
| 248 | + edge for cycles; delete `defineClientPlugin`, the plugins context, both | |
| 249 | + `KIND_TO_PLUGIN_KEY` maps, and `/plugins/$pluginId/$` (with redirects for | |
| 250 | + desktop-settings + toolkits). Keep `/integrations/add/$pluginKey` URL shape. | |
| 251 | + Don't forget the second axis: `useSecretProviderPlugins` (secrets page). | |
| 252 | +- **2.6** Detach `CredentialProvider` from `PluginSpec` (the interface in | |
| 253 | + `provider.ts` is already narrow — secret-store plugins keep it as their | |
| 254 | + seam); delete `plugin.ts`, `plugin-routes.ts`, `plugin-example`, and the | |
| 255 | + shim; mechanical sweep of the 93 e2e files; changesets for all seven | |
| 256 | + published packages. | |
| 257 | + | |
| 258 | +## Phase 3 — custom = concept package (3–4 PRs) — NOW BEFORE PHASE 2 | |
| 259 | + | |
| 260 | +(See RESEQUENCED note at top: lands side by side with the protocol plugins, | |
| 261 | +inside the apps plugin's existing costume; Phase 2 kills the registry | |
| 262 | +afterward, with apps converting in 2.3 as an already-stable model.) | |
| 263 | + | |
| 264 | +Port the rework's app model in place as the `apps` source kind's new model: | |
| 265 | +published source, manifest derived by execution, content-hashed immutable | |
| 266 | +versions, rollback = pointer move. New tables are additive greenfield (same | |
| 267 | +pattern as 1.1: coreTables + cloud mirror + migration, no readers first). | |
| 268 | +Existing app sources migrate one-off to published-source form — no dual model | |
| 269 | +within the apps kind. Then the OpenAPI/MCP generators + `GeneratorClaim` as an | |
| 270 | +optional on-ramp (spec → generated app source); GraphQL generator later. | |
| 271 | +Long-term option (explicitly out of scope): protocol kinds themselves become | |
| 272 | +generators and "custom" is the only kind. | |
| 273 | + | |
| 274 | +### Feasibility findings (two deep read-throughs, 2026-07-28) | |
| 275 | + | |
| 276 | +**Current plugin** (~7.6k LoC): only ~2.65k encodes the old model (store | |
| 277 | +collections, sync state machine, publish/discover/descriptor, routes, | |
| 278 | +console client). ~2.6k is model-agnostic and survives verbatim: all three | |
| 279 | +sandbox executors, the bundler stack, bindings/bridge, authoring + | |
| 280 | +standard-schema, conformance. ~1.9k (hand-rolled git client + source | |
| 281 | +fetchers) hangs on the publish-input decision below. Storage is entirely | |
| 282 | +generic `plugin_storage` rows + content-addressed blobs — zero apps-specific | |
| 283 | +tables or migrations exist, so new tables are pure greenfield. Cloud prod is | |
| 284 | +git-source-only; **source text is never persisted today (only compiled | |
| 285 | +bundles)**, so the one-off migration re-fetches source from the recorded git | |
| 286 | +URLs (fetcher exists). Load-bearing invariants to preserve: tool addresses | |
| 287 | +`tools.<app>.org.published.<name>` + the `<fileSlug>__<key>` naming rule, | |
| 288 | +app-slug↔integration-slug 1:1 guard, publish CAS | |
| 289 | +(`AppPublishConflictError`), tombstone-not-delete, token-in-provider-item | |
| 290 | +(never in the source row). Safety net: `custom-tools.test.ts` (534-line | |
| 291 | +cloud+selfhost console loop), local directory-picker, packed-CLI, desktop | |
| 292 | +e2e, ~2.9k LoC in-package tests. Pre-existing gaps flagged: R2 blobs never | |
| 293 | +GC'd and org purge misses them; cloud silently falls back to in-process | |
| 294 | +executor without the worker loader; host-cloudflare doesn't register apps. | |
| 295 | + | |
| 296 | +**Rework model** (portable tiers): T1 model proper ~3.9k LoC (authoring SDK, | |
| 297 | +publish-by-execution, projection, generator verification, storage port, | |
| 298 | +content hashing); T2 generators ~3.2k (fromOpenAPI 1k — complete; fromMCP | |
| 299 | +0.6k — computed group with real streamable-HTTP client; json-schema engine; | |
| 300 | +byte-for-byte GeneratorClaim verification; render endpoint); T4 host/registry | |
| 301 | +~2.9k explicitly skipped. Authoring is Standard Schema | |
| 302 | +(StandardSchemaV1 & StandardJSONSchemaV1) with raw-JSON-Schema literals as a | |
| 303 | +typed first-class alternative — matches our rule. Effect beta.93→beta.59 | |
| 304 | +delta is small (ConstraintDecoder → one-line fix; hand-roll | |
| 305 | +StandardJSONSchemaV1 ~10 lines; reuse core sha256Hex). NOT portable as-is: | |
| 306 | +rework `storage-db.ts` (write a new adapter over our tables against the | |
| 307 | +~90-line Storage port interface) and `engine.ts` (extract only ~700 LoC of | |
| 308 | +model-owned logic: computed-surface cache + requirement resolution + | |
| 309 | +runTool). Known rework gaps to fix at port time, not inherit: derive-time | |
| 310 | +execution is unsandboxed in-process (`code-executor.ts:23` records it) — our | |
| 311 | +port MUST run publish-time evaluation in the existing sandbox `collect` path | |
| 312 | +from day one; fan-in computed groups unsupported; OAuth discovery | |
| 313 | +unsupported. | |
| 314 | + | |
| 315 | +**Semantic deltas — decided by Rhys before 3.1 specs:** | |
| 316 | + | |
| 317 | +1. **Handler credential contract (the deep one).** Current: handlers get an | |
| 318 | + opaque callable `AppIntegrationClient` proxy that re-enters | |
| 319 | + `ctx.execute` — app code never sees credentials, can only call other | |
| 320 | + executor tools. Rework (D49): engine resolves credentials once and hands | |
| 321 | + the handler **credential fields as data** (`ResolvedConnection.fields`, | |
| 322 | + e.g. access_token) for direct HTTP — that's what makes generators | |
| 323 | + possible (generated code does raw fetch with auth) and what | |
| 324 | + `defineProvider` exists for. Not either/or in principle (a tool-call | |
| 325 | + bridge can coexist as a second requirement kind later), but the ported | |
| 326 | + model's primary contract is fields-as-data per the rework. | |
| 327 | +2. **Publish input.** Rework publish takes `SourceFiles` (push-based). | |
| 328 | + Decision: keep git/local-directory as _fetchers that produce | |
| 329 | + SourceFiles_ (preserves prod sources, the e2e suite, and the migration | |
| 330 | + path; git-client survives), with direct push publish added as the new | |
| 331 | + API. Delete-git-later stays open. | |
| 332 | +3. **App identity** moves from caller-supplied slug to the `defineApp` | |
| 333 | + export inside the source (rework shape). | |
| 334 | +4. **Versioning**: content hash of source IS the version identity; the | |
| 335 | + caller-supplied `sourceRef` + descriptor v6 die with the old model. | |
| 336 | +5. **Ownership**: app/version rows carry `space` and NO owner — spaces | |
| 337 | + curate apps, principals own custody (connections stay on the legacy | |
| 338 | + owner model in this phase; the opaque-OwnerRef swap is Phase 4). | |
| 339 | + | |
| 340 | +### Phase 3 PR sequence (revised) | |
| 341 | + | |
| 342 | +- **3.1** Port the model core as internal packages, no readers: authoring | |
| 343 | + SDK + runtime brands, publish pipeline (evaluation routed through the | |
| 344 | + sandbox `collect` seam), Storage port + adapter, greenfield tables | |
| 345 | + (`app`, `app_version`, `computed_surface` — space column, content-hash | |
| 346 | + identity, `current` pointer) in coreTables + cloud mirror + migration. | |
| 347 | + Ported rework tests come along (authoring-boundary, tool-groups, | |
| 348 | + publish). | |
| 349 | +- **3.2** Swap the apps plugin's internals onto the new model behind the | |
| 350 | + same costume: resolveTools/projectToolSchema/invokeTool read the new | |
| 351 | + tables; publish endpoint (`POST` source files); setCurrent/rollback + | |
| 352 | + versions listing routes; git/local-dir sync becomes fetch→publish. | |
| 353 | + Tool addresses and slug guards preserved; old collections | |
| 354 | + (`apps_descriptors`/`apps_tools`/`apps_sources`) still written by | |
| 355 | + NOTHING — one-off migration converts existing sources (re-fetch from | |
| 356 | + git), then the collections are dead. e2e: full existing custom-tools | |
| 357 | + suite green + new version-history/rollback scenario. | |
| 358 | +- **3.3** Console UI for the new model (sources → apps with versions, | |
| 359 | + publish status, rollback) replacing the ~1.3k LoC old client/panels. | |
| 360 | +- **3.4** Generators: fromOpenAPI + fromMCP + GeneratorClaim verification + | |
| 361 | + render endpoint, surfaced as the "add from spec/server" on-ramp. | |
| 362 | + (Vendors google/microsoft conversion ~1.15k LoC ports later with 0.2.) | |
| 363 | + | |
| 364 | +## Spaces: the rework's tenancy primitive (added Rhys, 2026-07-28) | |
| 365 | + | |
| 366 | +The rework's SDK has exactly one tenancy concept: `SpaceId` | |
| 367 | +(`.reference/executor-rework/docs/TENANCY.md`, `docs/MODEL.md`). Adopt it as | |
| 368 | +the end-state tenancy model here. The shape: | |
| 369 | + | |
| 370 | +- **Flat, no hierarchy, no default.** No parent, no inheritance, no | |
| 371 | + distinguished rows (rework D39/D44b). Sharing is an explicit act, never a | |
| 372 | + tree walk (R2 — re-ruled twice, do not relitigate). Every scoped row | |
| 373 | + carries a `space` column; every space-scoped route carries the space in | |
| 374 | + the URL path, never ambient context. | |
| 375 | +- **No org/project/team/user below the deployment edge.** The engine answers | |
| 376 | + "what is in this space"; the deployment answers "who is this and which | |
| 377 | + spaces may they touch" through named seams (`authorizeBrowserResource`, | |
| 378 | + `dashboardSpacesForPrincipal`, MCP space resolver). Membership is | |
| 379 | + authorized LIVE per request at the edge — projection may be cached, | |
| 380 | + authorization never. | |
| 381 | +- **Deployment mappings:** cloud = one WorkOS org mirrors lazily to one | |
| 382 | + space (mirror row `workosOrgId → space` is also the billing join — Autumn | |
| 383 | + keys on workosOrgId, never a space); self-host = one instance, one | |
| 384 | + found-or-created "Default" space, Better Auth org repurposed as the | |
| 385 | + instance membership table; local/desktop = one "local" space, one static | |
| 386 | + principal, single-tenancy declared via explicit sentinel, not defaulted. | |
| 387 | +- **Spaces curate, principals own.** Custody (secrets, connections' | |
| 388 | + secret-bearing halves) belongs to principals via opaque `OwnerRef` + | |
| 389 | + per-request `Reach` (R3); spaces curate apps and hold rows. Publishing an | |
| 390 | + app into a space IS its presence there — no installation rows, no | |
| 391 | + bindings; cross-space arrival (sharing/registry) is a future share-edge | |
| 392 | + row, not golden-path ceremony (D45/D42). | |
| 393 | +- Cross-org OAuth callbacks complete into the _initiating_ org's space; the | |
| 394 | + org header is a routing hint, not an authorization statement (R7). | |
| 395 | + | |
| 396 | +**DECIDED (Rhys, 2026-07-28): spaces land in the CORE PRODUCT NOW — Phase S, | |
| 397 | +before the apps port.** Apps then publishes into real spaces from birth. | |
| 398 | + | |
| 399 | +**Mapping onto this repo — the no-migration trick:** today's `tenant` column | |
| 400 | +is de facto the space — cloud writes the WorkOS org id there, self-host/local | |
| 401 | +write one constant. So: keep the persisted `tenant` column name (same rule as | |
| 402 | +`plugin_id`), and declare that **its value IS the space id**. Each org's | |
| 403 | +first space is grandfathered with `spaceId = <historical tenant value>` (the | |
| 404 | +org id); NEW spaces mint fresh ids written into the same column. The org→ | |
| 405 | +space mirror table records which space ids belong to which org (and carries | |
| 406 | +the billing join — Autumn keys on workosOrgId, never a space). Existing rows | |
| 407 | +never move; multi-space needs no schema change on scoped tables. "Space" is | |
| 408 | +contract vocabulary; "tenant" retreats to cloud-internal (see Vocabulary). | |
| 409 | + | |
| 410 | +### Phase S — spaces in core (before apps) | |
| 411 | + | |
| 412 | +- **S1 — space table + boot ensure (no readers).** Greenfield `space` table | |
| 413 | + (id, name, created_at) in coreTables + cloud mirror + migration, plus the | |
| 414 | + cloud `org→space` mirror table. Boot/first-contact ensures the degenerate | |
| 415 | + space per deployment: cloud lazily on first authenticated org contact | |
| 416 | + (grandfathered id = org id), self-host "Default", local "local". Same | |
| 417 | + no-readers pattern as PR 1.1. | |
| 418 | +- **S2 — space threading.** `SpaceId` brand in the SDK; the scoped-executor | |
| 419 | + seam resolves request → space id (today: identical to tenant) and stamps | |
| 420 | + it; reads interpret `tenant` columns as space ids. No behavior change — | |
| 421 | + this is the retype + vocabulary PR. `reach: "tenant"` reads as | |
| 422 | + "space-wide reach" from here on. | |
| 423 | +- **S3 — multi-space on cloud + product surface.** Create/rename space, | |
| 424 | + space switcher in the console, membership = org membership (all spaces of | |
| 425 | + the org for now), org→N mirror rows, space-scoped catalog/connection | |
| 426 | + views (rows follow automatically — they're keyed by the space id in the | |
| 427 | + tenant column). Billing stays org-keyed via the mirror. e2e: two spaces | |
| 428 | + in one org, isolation of catalogs/connections between them, switcher. | |
| 429 | +- **S4 — sharing model on spaces.** Connect-time "share with space" writes | |
| 430 | + the space's shared owner-ref (replaces the org tier semantically; the | |
| 431 | + persisted `owner: "org"|"user"` column keeps its name, read-side | |
| 432 | + reinterpretation only). Edge computes reach as | |
| 433 | + [principal, sharedRef(space) for each member space]. This is the bridge | |
| 434 | + to Phase 4's full owner-set reach. | |
| 435 | +- **Later (not scheduled): local multi-space, file-backed.** Each local | |
| 436 | + space is its own store file (portable — copy/backup/sync/check in | |
| 437 | + alongside a repo); the daemon opens one storage handle per space and | |
| 438 | + routes by space id; the engine still sees flat SpaceIds. Custody never | |
| 439 | + enters the space file — secrets stay in the principal-keyed provider | |
| 440 | + (keychain), so a copied file carries connection metadata, never tokens. | |
| 441 | +- Phase 4 completes the picture: space-in-path routes, | |
| 442 | + `dashboardSpacesForPrincipal`-style seams, reach as an explicit owner | |
| 443 | + set. | |
| 444 | + | |
| 445 | +Apps (Phase 3) then lands after S1/S2 — app rows carry a real `space` FK | |
| 446 | +from birth; S3/S4 can proceed in parallel with the apps port. | |
| 447 | + | |
| 448 | +Interaction with shipped Phase 1 work: the subject table and platform view | |
| 449 | +are edge/deployment concepts (who exists in this identity world, what may | |
| 450 | +an operator read) — they sit above spaces and survive unchanged; `reach: | |
| 451 | +"tenant"` becomes "space-wide reach" vocabulary-wise at Phase 4. | |
| 452 | + | |
| 453 | +## Vocabulary: retire "tenant" from consumer-facing surfaces (Rhys, 2026-07-27) | |
| 454 | + | |
| 455 | +Direction: the SDK should be agnostic to the consumer's data-model shape, the | |
| 456 | +way Composio's is — consumers pass an opaque user/owner id string and hang | |
| 457 | +whatever hierarchy they have (user, team, workspace) off it; the SDK never | |
| 458 | +models their org chart. The rework already designed this (`OwnerRef` opaque | |
| 459 | +brand, `Reach = {owners}`, deployments translate identity at the edge) — adopt | |
| 460 | +its vocabulary, not its schedule. "Tenant" remains a real _internal_ concept | |
| 461 | +(which Executor-cloud org owns the rows) but must not leak into surfaces | |
| 462 | +consumers touch. | |
| 463 | + | |
| 464 | +Rules, effective immediately: | |
| 465 | + | |
| 466 | +- Persisted column names (`tenant`, `subject`, `owner`) stay — renaming | |
| 467 | + storage is a cross-host migration for zero behavior (same rule as | |
| 468 | + `plugin_id`). | |
| 469 | +- Internal policy code (`reach: "tenant"`, `ExecutorOwnerPolicyContext`) may | |
| 470 | + keep the words. | |
| 471 | +- One contract noun: **`owner`** — the opaque ref naming who a connection | |
| 472 | + belongs to (end-user, team, workspace, or the org itself; the rework's | |
| 473 | + `OwnerRef`). "User" cannot be the contract noun because org-shared | |
| 474 | + connections are a load-bearing feature an end-user word can't cover. | |
| 475 | +- **"user" is presentation vocabulary only**, for surfaces whose rows genuinely | |
| 476 | + are the customer's end-users — the Phase 1.5 admin API says `users` because | |
| 477 | + the subject table is populated from authenticated principals. | |
| 478 | +- Hazard: the persisted `owner` column keeps its old meaning (the | |
| 479 | + `"org" | "user"` tier discriminator), while Phase 4 introduces the opaque | |
| 480 | + ref as a distinctly named type (`OwnerRef`). Same word, two meanings, | |
| 481 | + separated by layer — say so in policy-layer comments. | |
| 482 | +- The full swap (opaque owner refs at the SDK boundary, tenant retreating to a | |
| 483 | + cloud-internal detail) lands with the Phase 4 minimal SDK. | |
| 484 | + | |
| 485 | +## Phase 4 — minimal SDK + platform/product split | |
| 486 | + | |
| 487 | +New consumer boundary modeled on the rework: `createExecutor` / | |
| 488 | +`createRemoteExecutor`, no Effect at the boundary, `tools.list/execute` + | |
| 489 | +connections + the new users/admin surface; policies/toolkits become optional | |
| 490 | +layers consumers opt into, not baked opinions (Composio-style minimalism). | |
| 491 | + | |
| 492 | +Authorization end-state (Rhys, 2026-07-27): the engine enforces scope | |
| 493 | +mechanically but never decides policy — the host computes, per request, the | |
| 494 | +set of owners the request may read (the rework's `Reach = { owners }`), and | |
| 495 | +the storage predicate is just `row.owner ∈ reach.owners`. The 1.3 | |
| 496 | +`reach: "bound" | "tenant"` enum is a stepping stone and is replaced by this | |
| 497 | +set shape here: "bound" = a reach of one, "tenant" = all owners (wildcard so | |
| 498 | +cloud needn't enumerate), and in-between cases (team views, support sessions, | |
| 499 | +subset-restricted org keys) are just different sets. Writes stay hard-pinned | |
| 500 | +to a single owner; only read reach generalizes. Trust model becomes a | |
| 501 | +per-deployment choice: a trusted backend with a server key gets tenant-wide | |
| 502 | +reach and layers its own authorization (Composio-style); an end-user session | |
| 503 | +gets a reach of one. The enum-to-set swap is contained: the policy context is | |
| 504 | +constructed in exactly two places. | |
| 505 | +Start the platform/product package separation _during_ 2.3–2.6 (each PR moves | |
| 506 | +one thing to its final home) rather than as a big bang; the SDK boundary | |
| 507 | +itself lands last. | |
| 508 | + | |
| 509 | +## Bookkeeping | |
| 510 | + | |
| 511 | +- Create `MISTAKES.md` / `DESIRES.md` / `LEARNINGS.md` (required by AGENTS.md, | |
| 512 | + currently missing) with the first entries from this migration. | |
| 513 | +- `apps/local/src/db/executor-schema.ts` is already stale vs `coreTables` | |
| 514 | + (missing several columns); its "keep in sync" comment is untrustworthy — | |
| 515 | + runtime schema comes from the ensure path, not this file. | |