History

Registry-powered onboarding: full-page picker, quick add, spec-carried auth (#1851)

* 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.

Rhys Sullivan committed 2 months ago

Commit 30ddd791 parent 48ab9fe99 files · +7429 −876Browse files

Unified · Split

99 files
  1. apps/cloud/src/routeTree.gen.ts +29 −0
  2. apps/host-cloudflare/web/routeTree.gen.ts +25 −0
  3. apps/host-selfhost/web/routeTree.gen.ts +25 −0
  4. apps/local/package.json +1 −1
  5. bun.lock +26 −0
  6. e2e/cloud/frontend-error-reporting.test.ts +3 −1
  7. e2e/scenarios/auth-methods-ui.test.ts +1 −1
  8. e2e/scenarios/first-party-oauth.test.ts +4 −29
  9. e2e/scenarios/google-disabled-api.test.ts +9 −12
  10. e2e/scenarios/google-health-checks.test.ts +29 −10
  11. e2e/scenarios/google-photos-preset-ui.test.ts +98 −23
  12. e2e/scenarios/integrations-catalog-search.test.ts +103 −64
  13. e2e/scenarios/mcp-catalog-sync-ui.test.ts +1 −1
  14. e2e/scenarios/provider-plugins-ui.test.ts +85 −46
  15. e2e/selfhost/auth-methods-ui.test.ts +1 −1
  16. e2e/selfhost/detected-auth-immutable-ui.test.ts +15 −14
  17. e2e/selfhost/mcp-auth-required-add.test.ts +1 −1
  18. e2e/selfhost/posthog-mcp-oauth.test.ts +1 −1
  19. package.json +1 −0
  20. packages/app/src/routeTree.gen.ts +23 −0
  21. packages/app/src/web/shell.tsx +6 −0
  22. packages/core/sdk/src/client.ts +60 −0
  23. packages/core/sdk/src/plugin.ts +4 −0
  24. packages/onboarding-demo/CHANGELOG.md +1 −0
  25. packages/onboarding-demo/README.md +53 −0
  26. packages/onboarding-demo/index.html +18 −0
  27. packages/onboarding-demo/package.json +32 −0
  28. packages/onboarding-demo/src/app.tsx +202 −0
  29. packages/onboarding-demo/src/catalog.ts +163 −0
  30. packages/onboarding-demo/src/fixtures.ts +240 −0
  31. packages/onboarding-demo/src/flow.ts +111 −0
  32. packages/onboarding-demo/src/main.tsx +12 −0
  33. packages/onboarding-demo/src/next-flow.tsx +304 −0
  34. packages/onboarding-demo/src/screens/add-account-modal.tsx +318 −0
  35. packages/onboarding-demo/src/screens/add-openapi-page.tsx +261 −0
  36. packages/onboarding-demo/src/screens/connect-dialog.tsx +279 −0
  37. packages/onboarding-demo/src/screens/integration-detail.tsx +105 −0
  38. packages/onboarding-demo/src/screens/integrations-page.tsx +142 −0
  39. packages/onboarding-demo/src/screens/next/authenticate.tsx +229 −0
  40. packages/onboarding-demo/src/screens/next/browse.tsx +234 −0
  41. packages/onboarding-demo/src/screens/next/custom.tsx +242 −0
  42. packages/onboarding-demo/src/screens/next/integration-detail.tsx +209 −0
  43. packages/onboarding-demo/src/shell.tsx +97 −0
  44. packages/onboarding-demo/src/step-state.ts +45 −0
  45. packages/onboarding-demo/src/styles.css +5 −0
  46. packages/onboarding-demo/tsconfig.json +15 −0
  47. packages/onboarding-demo/vite.config.ts +12 −0
  48. packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx +19 −2
  49. packages/plugins/graphql/src/react/integration-plugin.ts +47 −3
  50. packages/plugins/mcp/src/api/group.ts +11 −0
  51. packages/plugins/mcp/src/api/handlers.test.ts +33 −0
  52. packages/plugins/mcp/src/api/handlers.ts +2 −0
  53. packages/plugins/mcp/src/react/AddMcpIntegration.tsx +32 −25
  54. packages/plugins/mcp/src/react/auth-method-config.ts +53 −0
  55. packages/plugins/mcp/src/react/integration-plugin.tsx +66 −3
  56. packages/plugins/mcp/src/sdk/connection.ts +6 −5
  57. packages/plugins/mcp/src/sdk/discover.ts +55 −4
  58. packages/plugins/mcp/src/sdk/errors.ts +5 −0
  59. packages/plugins/mcp/src/sdk/plugin.ts +27 −19
  60. packages/plugins/mcp/src/sdk/presets.ts +0 −8
  61. packages/plugins/mcp/src/sdk/types.ts +6 −0
  62. packages/plugins/openapi/src/api/group.ts +3 −0
  63. packages/plugins/openapi/src/api/handlers.ts +1 −0
  64. packages/plugins/openapi/src/providers/google/discovery.test.ts +28 −0
  65. packages/plugins/openapi/src/providers/google/discovery.ts +62 −28
  66. packages/plugins/openapi/src/providers/google/presets.ts +1 −0
  67. packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts +28 −0
  68. packages/plugins/openapi/src/providers/google/spec-format-adapter.ts +2 −0
  69. packages/plugins/openapi/src/providers/microsoft/presets.ts +1 −0
  70. packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts +19 −0
  71. packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts +21 −0
  72. packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx +44 −5
  73. packages/plugins/openapi/src/react/integration-plugin.test.ts +241 −0
  74. packages/plugins/openapi/src/react/integration-plugin.ts +252 −17
  75. packages/plugins/openapi/src/sdk/config.ts +5 −0
  76. packages/plugins/openapi/src/sdk/derive-auth.test.ts +42 −0
  77. packages/plugins/openapi/src/sdk/derive-auth.ts +24 −11
  78. packages/plugins/openapi/src/sdk/plugin.ts +11 −1
  79. packages/plugins/openapi/src/sdk/presets.ts +3 −8
  80. packages/plugins/openapi/src/sdk/preview.ts +17 −10
  81. packages/plugins/openapi/src/sdk/spec-format.ts +12 −1
  82. packages/react/src/api/analytics.test.ts +1 −1
  83. packages/react/src/api/analytics.tsx +11 −1
  84. packages/react/src/components/add-account-modal.tsx +27 −2
  85. packages/react/src/components/auth-method-list-editor.tsx +33 −15
  86. packages/react/src/components/credential-guidance.tsx +168 −0
  87. packages/react/src/components/integration-favicon.tsx +0 −1
  88. packages/react/src/console-routes.ts +2 −0
  89. packages/react/src/lib/auth-placements.test.ts +23 −1
  90. packages/react/src/lib/auth-placements.tsx +16 −0
  91. packages/react/src/lib/integrations-sh-catalog.ts +440 −43
  92. packages/react/src/multiplayer/shell.tsx +10 −10
  93. packages/react/src/pages/integration-add.tsx +10 −1
  94. packages/react/src/pages/integration-browse.tsx +1041 −0
  95. packages/react/src/pages/integrations.tsx +24 −444
  96. packages/react/src/routes/integrations.add.$pluginKey.tsx +20 −2
  97. packages/react/src/routes/integrations.browse.tsx +7 −0
  98. packages/react/src/routes/routeTree.gen.ts +22 −0
  99. plans/kill-plugin-system.md +515 −0
apps/cloud/src/routeTree.gen.ts+29−0
@@ -24,6 +24,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteIm · 23 unmodified lines
24import { Route as ApiKeysRouteImport } from './routes/app/api-keys'24import { Route as ApiKeysRouteImport } from './routes/app/api-keys'
25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
26import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'26import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'
27import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse'
27import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'28import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
28import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'29import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
29import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'30import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'
@@ -115,6 +116,14 @@ const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({ · 85 unmodified lines
115 path: '/{-$orgSlug}/resume/$executionId',116 path: '/{-$orgSlug}/resume/$executionId',
116 getParentRoute: () => rootRouteImport,117 getParentRoute: () => rootRouteImport,
117} as any)118} as any)
119const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute =
120 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update(
121 {
122 id: '/{-$orgSlug}/integrations/browse',
123 path: '/{-$orgSlug}/integrations/browse',
124 getParentRoute: () => rootRouteImport,
125 } as any,
126 )
118const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =127const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =
119 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(128 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(
120 {129 {
@@ -172,6 +181,7 @@ export interface FileRoutesByFullPath { · 51 unmodified lines
172 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute181 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
173 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute182 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
174 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute183 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
184 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
175 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute185 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
176 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute186 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
177 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute187 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -194,6 +204,7 @@ export interface FileRoutesByTo { · 16 unmodified lines
194 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute204 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
195 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute205 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
196 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute206 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
207 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
197 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute208 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
198 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute209 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
199 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute210 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -217,6 +228,7 @@ export interface FileRoutesById { · 17 unmodified lines
217 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute228 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute
218 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute229 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
219 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute230 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
231 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
220 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute232 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
221 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute233 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
222 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute234 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -241,6 +253,7 @@ export interface FileRouteTypes { · 18 unmodified lines
241 | '/{-$orgSlug}/billing/plans'253 | '/{-$orgSlug}/billing/plans'
242 | '/{-$orgSlug}/connect/$integrationSlug'254 | '/{-$orgSlug}/connect/$integrationSlug'
243 | '/{-$orgSlug}/integrations/$namespace'255 | '/{-$orgSlug}/integrations/$namespace'
256 | '/{-$orgSlug}/integrations/browse'
244 | '/{-$orgSlug}/resume/$executionId'257 | '/{-$orgSlug}/resume/$executionId'
245 | '/{-$orgSlug}/toolkits/$toolkitSlug'258 | '/{-$orgSlug}/toolkits/$toolkitSlug'
246 | '/{-$orgSlug}/integrations/add/$pluginKey'259 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -263,6 +276,7 @@ export interface FileRouteTypes { · 16 unmodified lines
263 | '/{-$orgSlug}/billing/plans'276 | '/{-$orgSlug}/billing/plans'
264 | '/{-$orgSlug}/connect/$integrationSlug'277 | '/{-$orgSlug}/connect/$integrationSlug'
265 | '/{-$orgSlug}/integrations/$namespace'278 | '/{-$orgSlug}/integrations/$namespace'
279 | '/{-$orgSlug}/integrations/browse'
266 | '/{-$orgSlug}/resume/$executionId'280 | '/{-$orgSlug}/resume/$executionId'
267 | '/{-$orgSlug}/toolkits/$toolkitSlug'281 | '/{-$orgSlug}/toolkits/$toolkitSlug'
268 | '/{-$orgSlug}/integrations/add/$pluginKey'282 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -285,6 +299,7 @@ export interface FileRouteTypes { · 16 unmodified lines
285 | '/{-$orgSlug}/billing_/plans'299 | '/{-$orgSlug}/billing_/plans'
286 | '/{-$orgSlug}/connect/$integrationSlug'300 | '/{-$orgSlug}/connect/$integrationSlug'
287 | '/{-$orgSlug}/integrations/$namespace'301 | '/{-$orgSlug}/integrations/$namespace'
302 | '/{-$orgSlug}/integrations/browse'
288 | '/{-$orgSlug}/resume/$executionId'303 | '/{-$orgSlug}/resume/$executionId'
289 | '/{-$orgSlug}/toolkits/$toolkitSlug'304 | '/{-$orgSlug}/toolkits/$toolkitSlug'
290 | '/{-$orgSlug}/integrations/add/$pluginKey'305 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -307,6 +322,7 @@ export interface RootRouteChildren { · 16 unmodified lines
307 Billing_DotplansRoute: typeof Billing_DotplansRoute322 Billing_DotplansRoute: typeof Billing_DotplansRoute
308 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute323 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
309 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute324 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
325 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
310 ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute326 ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute
311 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute327 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
312}328}
@@ -418,6 +434,13 @@ declare module '@tanstack/react-router' { · 105 unmodified lines
418 preLoaderRoute: typeof ResumeDotexecutionIdRouteImport434 preLoaderRoute: typeof ResumeDotexecutionIdRouteImport
419 parentRoute: typeof rootRouteImport435 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 '/{-$orgSlug}/integrations/$namespace': {444 '/{-$orgSlug}/integrations/$namespace': {
422 id: '/{-$orgSlug}/integrations/$namespace'445 id: '/{-$orgSlug}/integrations/$namespace'
423 path: '/{-$orgSlug}/integrations/$namespace'446 path: '/{-$orgSlug}/integrations/$namespace'
@@ -511,6 +534,8 @@ const rootRouteChildren: RootRouteChildren = { · 87 unmodified lines
511 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,534 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
512 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:535 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
513 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,536 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
537 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute:
538 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute,
514 ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute,539 ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute,
515 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:540 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:
516 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute,541 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute,
@@ -520,11 +545,15 @@ export const routeTree = rootRouteImport · 3 unmodified lines
520 ._addFileTypes<FileRouteTypes>()545 ._addFileTypes<FileRouteTypes>()
521 546
522import type { getRouter } from './router.tsx'547import type { getRouter } from './router.tsx'
548
523import type { startInstance } from './start.ts'549import type { startInstance } from './start.ts'
550
524declare module '@tanstack/react-start' {551declare module '@tanstack/react-start' {
525 interface Register {552 interface Register {
526 ssr: true553 ssr: true
554
527 router: Awaited<ReturnType<typeof getRouter>>555 router: Awaited<ReturnType<typeof getRouter>>
556
528 config: Awaited<ReturnType<typeof startInstance.getOptions>>557 config: Awaited<ReturnType<typeof startInstance.getOptions>>
529 }558 }
530}559}
apps/host-cloudflare/web/routeTree.gen.ts+25−0
@@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp · 16 unmodified lines
17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts'17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts'
18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
19import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'19import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse'
20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId'23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId'
@@ -76,6 +77,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = · 53 unmodified lines
76 getParentRoute: () => rootRouteImport,77 getParentRoute: () => rootRouteImport,
77 } as any,78 } as any,
78 )79 )
80const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute =
81 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update(
82 {
83 id: '/{-$orgSlug}/integrations/browse',
84 path: '/{-$orgSlug}/integrations/browse',
85 getParentRoute: () => rootRouteImport,
86 } as any,
87 )
79const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =88const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =
80 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(89 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(
81 {90 {
@@ -128,6 +137,7 @@ export interface FileRoutesByFullPath { · 46 unmodified lines
128 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute137 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
129 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute138 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
130 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute139 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
140 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
131 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute141 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
132 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute142 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
133 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute143 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -143,6 +153,7 @@ export interface FileRoutesByTo { · 9 unmodified lines
143 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute153 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
144 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute154 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
145 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute155 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
156 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
146 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute157 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
147 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute158 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
148 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute159 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -159,6 +170,7 @@ export interface FileRoutesById { · 10 unmodified lines
159 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute170 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
160 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute171 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
161 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute172 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
173 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
162 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute174 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
163 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute175 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
164 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute176 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -176,6 +188,7 @@ export interface FileRouteTypes { · 11 unmodified lines
176 | '/{-$orgSlug}/artifacts/$artifactId'188 | '/{-$orgSlug}/artifacts/$artifactId'
177 | '/{-$orgSlug}/connect/$integrationSlug'189 | '/{-$orgSlug}/connect/$integrationSlug'
178 | '/{-$orgSlug}/integrations/$namespace'190 | '/{-$orgSlug}/integrations/$namespace'
191 | '/{-$orgSlug}/integrations/browse'
179 | '/{-$orgSlug}/resume/$executionId'192 | '/{-$orgSlug}/resume/$executionId'
180 | '/{-$orgSlug}/toolkits/$toolkitSlug'193 | '/{-$orgSlug}/toolkits/$toolkitSlug'
181 | '/{-$orgSlug}/integrations/add/$pluginKey'194 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -191,6 +204,7 @@ export interface FileRouteTypes { · 9 unmodified lines
191 | '/{-$orgSlug}/artifacts/$artifactId'204 | '/{-$orgSlug}/artifacts/$artifactId'
192 | '/{-$orgSlug}/connect/$integrationSlug'205 | '/{-$orgSlug}/connect/$integrationSlug'
193 | '/{-$orgSlug}/integrations/$namespace'206 | '/{-$orgSlug}/integrations/$namespace'
207 | '/{-$orgSlug}/integrations/browse'
194 | '/{-$orgSlug}/resume/$executionId'208 | '/{-$orgSlug}/resume/$executionId'
195 | '/{-$orgSlug}/toolkits/$toolkitSlug'209 | '/{-$orgSlug}/toolkits/$toolkitSlug'
196 | '/{-$orgSlug}/integrations/add/$pluginKey'210 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -206,6 +220,7 @@ export interface FileRouteTypes { · 9 unmodified lines
206 | '/{-$orgSlug}/artifacts/$artifactId'220 | '/{-$orgSlug}/artifacts/$artifactId'
207 | '/{-$orgSlug}/connect/$integrationSlug'221 | '/{-$orgSlug}/connect/$integrationSlug'
208 | '/{-$orgSlug}/integrations/$namespace'222 | '/{-$orgSlug}/integrations/$namespace'
223 | '/{-$orgSlug}/integrations/browse'
209 | '/{-$orgSlug}/resume/$executionId'224 | '/{-$orgSlug}/resume/$executionId'
210 | '/{-$orgSlug}/toolkits/$toolkitSlug'225 | '/{-$orgSlug}/toolkits/$toolkitSlug'
211 | '/{-$orgSlug}/integrations/add/$pluginKey'226 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -221,6 +236,7 @@ export interface RootRouteChildren { · 9 unmodified lines
221 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute236 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
222 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute237 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
223 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute238 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
239 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
224 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute240 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
225 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute241 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
226 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute242 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
@@ -284,6 +300,13 @@ declare module '@tanstack/react-router' { · 57 unmodified lines
284 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport300 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport
285 parentRoute: typeof rootRouteImport301 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 '/{-$orgSlug}/integrations/$namespace': {310 '/{-$orgSlug}/integrations/$namespace': {
288 id: '/{-$orgSlug}/integrations/$namespace'311 id: '/{-$orgSlug}/integrations/$namespace'
289 path: '/{-$orgSlug}/integrations/$namespace'312 path: '/{-$orgSlug}/integrations/$namespace'
@@ -369,6 +392,8 @@ const rootRouteChildren: RootRouteChildren = { · 79 unmodified lines
369 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,392 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
370 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:393 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
371 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,394 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
395 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute:
396 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute,
372 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:397 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:
373 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute,398 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute,
374 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:399 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:
apps/host-selfhost/web/routeTree.gen.ts+25−0
@@ -21,6 +21,7 @@ import { Route as AdminRouteImport } from './routes/app/admin' · 20 unmodified lines
21import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code'21import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code'
22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
24import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse'
24import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'26import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
26import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId'27import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId'
@@ -101,6 +102,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = · 74 unmodified lines
101 getParentRoute: () => rootRouteImport,102 getParentRoute: () => rootRouteImport,
102 } as any,103 } as any,
103 )104 )
105const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute =
106 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update(
107 {
108 id: '/{-$orgSlug}/integrations/browse',
109 path: '/{-$orgSlug}/integrations/browse',
110 getParentRoute: () => rootRouteImport,
111 } as any,
112 )
104const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =113const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =
105 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(114 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(
106 {115 {
@@ -157,6 +166,7 @@ export interface FileRoutesByFullPath { · 50 unmodified lines
157 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute166 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
158 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute167 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
159 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute168 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
169 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
160 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute170 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
161 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute171 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
162 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute172 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -176,6 +186,7 @@ export interface FileRoutesByTo { · 13 unmodified lines
176 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute186 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
177 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute187 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
178 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute188 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
189 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
179 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute190 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
180 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute191 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
181 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute192 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -196,6 +207,7 @@ export interface FileRoutesById { · 14 unmodified lines
196 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute207 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
197 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute208 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
198 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute209 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
210 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
199 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute211 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
200 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute212 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
201 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute213 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -217,6 +229,7 @@ export interface FileRouteTypes { · 15 unmodified lines
217 | '/{-$orgSlug}/artifacts/$artifactId'229 | '/{-$orgSlug}/artifacts/$artifactId'
218 | '/{-$orgSlug}/connect/$integrationSlug'230 | '/{-$orgSlug}/connect/$integrationSlug'
219 | '/{-$orgSlug}/integrations/$namespace'231 | '/{-$orgSlug}/integrations/$namespace'
232 | '/{-$orgSlug}/integrations/browse'
220 | '/{-$orgSlug}/resume/$executionId'233 | '/{-$orgSlug}/resume/$executionId'
221 | '/{-$orgSlug}/toolkits/$toolkitSlug'234 | '/{-$orgSlug}/toolkits/$toolkitSlug'
222 | '/{-$orgSlug}/integrations/add/$pluginKey'235 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -236,6 +249,7 @@ export interface FileRouteTypes { · 13 unmodified lines
236 | '/{-$orgSlug}/artifacts/$artifactId'249 | '/{-$orgSlug}/artifacts/$artifactId'
237 | '/{-$orgSlug}/connect/$integrationSlug'250 | '/{-$orgSlug}/connect/$integrationSlug'
238 | '/{-$orgSlug}/integrations/$namespace'251 | '/{-$orgSlug}/integrations/$namespace'
252 | '/{-$orgSlug}/integrations/browse'
239 | '/{-$orgSlug}/resume/$executionId'253 | '/{-$orgSlug}/resume/$executionId'
240 | '/{-$orgSlug}/toolkits/$toolkitSlug'254 | '/{-$orgSlug}/toolkits/$toolkitSlug'
241 | '/{-$orgSlug}/integrations/add/$pluginKey'255 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -255,6 +269,7 @@ export interface FileRouteTypes { · 13 unmodified lines
255 | '/{-$orgSlug}/artifacts/$artifactId'269 | '/{-$orgSlug}/artifacts/$artifactId'
256 | '/{-$orgSlug}/connect/$integrationSlug'270 | '/{-$orgSlug}/connect/$integrationSlug'
257 | '/{-$orgSlug}/integrations/$namespace'271 | '/{-$orgSlug}/integrations/$namespace'
272 | '/{-$orgSlug}/integrations/browse'
258 | '/{-$orgSlug}/resume/$executionId'273 | '/{-$orgSlug}/resume/$executionId'
259 | '/{-$orgSlug}/toolkits/$toolkitSlug'274 | '/{-$orgSlug}/toolkits/$toolkitSlug'
260 | '/{-$orgSlug}/integrations/add/$pluginKey'275 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -274,6 +289,7 @@ export interface RootRouteChildren { · 13 unmodified lines
274 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute289 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
275 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute290 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
276 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute291 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
292 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
277 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute293 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
278 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute294 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
279 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute295 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
@@ -365,6 +381,13 @@ declare module '@tanstack/react-router' { · 85 unmodified lines
365 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport381 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport
366 parentRoute: typeof rootRouteImport382 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 '/{-$orgSlug}/integrations/$namespace': {391 '/{-$orgSlug}/integrations/$namespace': {
369 id: '/{-$orgSlug}/integrations/$namespace'392 id: '/{-$orgSlug}/integrations/$namespace'
370 path: '/{-$orgSlug}/integrations/$namespace'393 path: '/{-$orgSlug}/integrations/$namespace'
@@ -455,6 +478,8 @@ const rootRouteChildren: RootRouteChildren = { · 84 unmodified lines
455 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,478 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
456 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:479 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
457 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,480 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
481 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute:
482 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute,
458 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:483 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:
459 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute,484 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute,
460 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:485 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:
apps/local/package.json+1−1
@@ -10,7 +10,7 @@ · 9 unmodified lines
10 "scripts": {10 "scripts": {
11 "dev": "bun run dev:proxy && bun run dev:vite",11 "dev": "bun run dev:proxy && bun run dev:vite",
12 "dev:proxy": "portless proxy start --multiplex --port 1355 || true",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 "build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build",14 "build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build",
15 "start": "bun run src/serve.ts",15 "start": "bun run src/serve.ts",
16 "db:generate": "drizzle-kit generate",16 "db:generate": "drizzle-kit generate",
bun.lock+26−0
@@ -862,6 +862,30 @@ · 861 unmodified lines
862 "vitest": "catalog:",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 "packages/plugins/desktop-settings": {889 "packages/plugins/desktop-settings": {
866 "name": "@executor-js/plugin-desktop-settings",890 "name": "@executor-js/plugin-desktop-settings",
867 "version": "1.6.5",891 "version": "1.6.5",
@@ -1804,6 +1828,8 @@ · 936 unmodified lines
1804 1828
1805 "@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=="],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 "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"],1833 "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"],
1808 1834
1809 "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"],1835 "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"],
e2e/cloud/frontend-error-reporting.test.ts+3−1
@@ -73,7 +73,9 @@ scenario( · 72 unmodified lines
73 73
74 await step("Open the integrations console", async () => {74 await step("Open the integrations console", async () => {
75 await visit(page, "/integrations");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 let faulted = 0;81 let faulted = 0;
e2e/scenarios/auth-methods-ui.test.ts+1−1
@@ -46,7 +46,7 @@ scenario( · 45 unmodified lines
46 });46 });
47 47
48 await step("The probe seeded the detected method", async () => {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 await step("Declare an API key method alongside it", async () => {52 await step("Declare an API key method alongside it", async () => {
e2e/scenarios/first-party-oauth.test.ts+4−29
@@ -26,7 +26,7 @@ import { · 25 unmodified lines
26 26
27import { scenario } from "../src/scenario";27import { scenario } from "../src/scenario";
28import { Api, Browser, Target } from "../src/services";28import { Api, Browser, Target } from "../src/services";
29import { clickToReveal, visit } from "../src/surfaces/browser";29import { visit } from "../src/surfaces/browser";
30 30
31const api = composePluginApi([openApiHttpPlugin()] as const);31const api = composePluginApi([openApiHttpPlugin()] as const);
32 32
@@ -181,35 +181,10 @@ scenario( · 148 unmodified lines
181 const client = yield* makeApiClient(api, identity);181 const client = yield* makeApiClient(api, identity);
182 182
183 yield* browser.session(identity, async ({ page, step }) => {183 yield* browser.session(identity, async ({ page, step }) => {
184 await step("Open the Google integration catalog", async () => {184 await step("Open the integrations registry browser", async () => {
185 await visit(page, "/integrations");185 await visit(page, "/integrations/browse");
186 const dialog = page.getByRole("dialog", { name: "Connect an integration" });186 await page.getByPlaceholder(/Search integrations, or paste a URL/).waitFor();
187 await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog);
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 // The Executor-owned Google app is withheld from every listing: it is no190 // The Executor-owned Google app is withheld from every listing: it is no
e2e/scenarios/google-disabled-api.test.ts+9−12
@@ -117,24 +117,21 @@ const createGoogleEmulator = Effect.gen(function* () { · 116 unmodified lines
117const addGooglePresetFromCatalog = (117const addGooglePresetFromCatalog = (
118 browser: BrowserSurface,118 browser: BrowserSurface,
119 identity: Identity,119 identity: Identity,
120 presetId: string,
120 presetName: string,121 presetName: string,
121 slug: string,122 slug: string,
122) =>123) =>
123 browser.session(identity, async ({ page, step }) => {124 browser.session(identity, async ({ page, step }) => {
124 await step(`Open ${presetName} from the connect catalog`, async () => {125 await step(`Open the ${presetName} preset's add flow`, async () => {
125 await page.goto("/integrations", { waitUntil: "networkidle" });126 // The connect dialog became the full-page picker, and registry-listed
126 await page127 // provider presets no longer render cards there — the preset deep link
127 .getByRole("button", { name: /Connect/ })128 // is the stable route to a preset-configured add flow.
128 .first()129 await page.goto(`/integrations/add/openapi?preset=${presetId}`, {
129 .click();130 waitUntil: "networkidle",
130 const dialog = page.getByRole("dialog", { name: "Connect an integration" });131 });
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();
134 });132 });
135 133
136 await step(`Add the ${presetName} integration`, async () => {134 await step(`Add the ${presetName} integration`, async () => {
137 await page.waitForURL(/\/integrations\/add\/openapi/);
138 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();135 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
139 const button = page.getByRole("button", { name: "Add integration" });136 const button = page.getByRole("button", { name: "Add integration" });
140 await button.waitFor({ timeout: 120_000 });137 await button.waitFor({ timeout: 120_000 });
@@ -218,7 +215,7 @@ scenario( · 77 unmodified lines
218 215
219 yield* Effect.ensuring(216 yield* Effect.ensuring(
220 Effect.gen(function* () {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 const stored = yield* client.integrations.healthCheckGet({ params: { slug } });220 const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
224 expect(stored?.operation, "the Gmail preset declares its labels probe").toBe(221 expect(stored?.operation, "the Gmail preset declares its labels probe").toBe(
e2e/scenarios/google-health-checks.test.ts+29−10
@@ -17,13 +17,15 @@ import { createEmulatorInstance } from "../src/emulator-instance"; · 16 unmodified lines
17import { scenario } from "../src/scenario";17import { scenario } from "../src/scenario";
18import { Api, Browser, Target } from "../src/services";18import { Api, Browser, Target } from "../src/services";
19import type { Identity, Target as TargetShape } from "../src/target";19import type { Identity, Target as TargetShape } from "../src/target";
20import { type BrowserSurface, clickToReveal, visit } from "../src/surfaces/browser";20import { type BrowserSurface, visit } from "../src/surfaces/browser";
21 21
22const api = composePluginApi([openApiHttpPlugin()] as const);22const api = composePluginApi([openApiHttpPlugin()] as const);
23type Client = HttpApiClient.ForApi<typeof api>;23type Client = HttpApiClient.ForApi<typeof api>;
24const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2");24const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2");
25const CONNECTION = ConnectionName.make("main");25const CONNECTION = ConnectionName.make("main");
26const GOOGLE_EMULATOR_ACCOUNT_EMAIL = "testuser@gmail.com";26const GOOGLE_EMULATOR_ACCOUNT_EMAIL = "testuser@gmail.com";
27const googleDiscoveryUrl = (service: string, version: string) =>
28 `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`;
27 29
28const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;30const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
29 31
@@ -79,19 +81,18 @@ const createGoogleEmulator = Effect.gen(function* () { · 49 unmodified lines
79 return { client, baseUrl };81 return { client, baseUrl };
80});82});
81 83
82const addGooglePresetFromCatalog = (84const addGooglePresetFromDirectUrl = (
83 browser: BrowserSurface,85 browser: BrowserSurface,
84 identity: Identity,86 identity: Identity,
85 presetName: string,87 presetName: string,
88 presetId: string,
89 presetUrl: string,
86 slug: string,90 slug: string,
87) =>91) =>
88 browser.session(identity, async ({ page, step }) => {92 browser.session(identity, async ({ page, step }) => {
89 await step(`Open ${presetName} from the connect catalog`, async () => {93 await step(`Open the ${presetName} preset add flow directly`, async () => {
90 await visit(page, "/integrations");94 const search = new URLSearchParams({ preset: presetId, url: presetUrl });
91 const dialog = page.getByRole("dialog", { name: "Connect an integration" });95 await visit(page, `/integrations/add/openapi?${search}`);
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();
95 });96 });
96 97
97 await step(`Add the ${presetName} integration`, async () => {98 await step(`Add the ${presetName} integration`, async () => {
@@ -201,6 +202,8 @@ scenario( · 103 unmodified lines
201 const rows = [202 const rows = [
202 {203 {
203 presetName: "Google Calendar",204 presetName: "Google Calendar",
205 presetId: "google-calendar",
206 presetUrl: googleDiscoveryUrl("calendar", "v3"),
204 slug: IntegrationSlug.make("google_calendar"),207 slug: IntegrationSlug.make("google_calendar"),
205 oauthClient: OAuthClientSlug.make(unique("google_calendar_oauth")),208 oauthClient: OAuthClientSlug.make(unique("google_calendar_oauth")),
206 expectedHealthOperation: "calendar.calendarList.list",209 expectedHealthOperation: "calendar.calendarList.list",
@@ -209,6 +212,8 @@ scenario( · 2 unmodified lines
209 },212 },
210 {213 {
211 presetName: "Gmail",214 presetName: "Gmail",
215 presetId: "google-gmail",
216 presetUrl: googleDiscoveryUrl("gmail", "v1"),
212 slug: IntegrationSlug.make("google_gmail"),217 slug: IntegrationSlug.make("google_gmail"),
213 oauthClient: OAuthClientSlug.make(unique("google_gmail_oauth")),218 oauthClient: OAuthClientSlug.make(unique("google_gmail_oauth")),
214 expectedHealthOperation: "gmail.users.labels.list",219 expectedHealthOperation: "gmail.users.labels.list",
@@ -220,7 +225,14 @@ scenario( · 5 unmodified lines
220 yield* Effect.ensuring(225 yield* Effect.ensuring(
221 Effect.gen(function* () {226 Effect.gen(function* () {
222 for (const row of rows) {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 const stored = yield* client.integrations.healthCheckGet({ params: { slug: row.slug } });237 const stored = yield* client.integrations.healthCheckGet({ params: { slug: row.slug } });
226 expect(stored?.operation, `${row.presetName} stored health check`).toBe(238 expect(stored?.operation, `${row.presetName} stored health check`).toBe(
@@ -324,7 +336,14 @@ scenario( · 97 unmodified lines
324 336
325 yield* Effect.ensuring(337 yield* Effect.ensuring(
326 Effect.gen(function* () {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 const stored = yield* client.integrations.healthCheckGet({ params: { slug } });348 const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
330 expect(stored, "Google Sheets catalog preset declares no health check").toBeNull();349 expect(stored, "Google Sheets catalog preset declares no health check").toBeNull();
e2e/scenarios/google-photos-preset-ui.test.ts+98−23
@@ -3,10 +3,15 @@ import { Effect } from "effect"; · 2 unmodified lines
3 3
4import { scenario } from "../src/scenario";4import { scenario } from "../src/scenario";
5import { Browser, Target } from "../src/services";5import { Browser, Target } from "../src/services";
6import { clickToReveal, visit } from "../src/surfaces/browser";6import { visit } from "../src/surfaces/browser";
7 7
8const librarySpecUrl = "https://integrations.sh/specs/google/google-photos-library.json";
9const 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.
8scenario(13scenario(
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 Effect.gen(function* () {16 Effect.gen(function* () {
12 const target = yield* Target;17 const target = yield* Target;
@@ -14,30 +19,100 @@ scenario( · 1 unmodified line
14 const identity = yield* target.newIdentity();19 const identity = yield* target.newIdentity();
15 20
16 yield* browser.session(identity, async ({ page, step }) => {21 yield* browser.session(identity, async ({ page, step }) => {
17 await step(22 await step("Stub the two Google Photos registry products", async () => {
18 "Find the separated Google Photos presets from the integrations picker",23 await page.route("https://integrations.sh/api/search*", (route) =>
19 async () => {24 route.fulfill({
20 await visit(page, "/integrations");25 contentType: "application/json",
21 const dialog = page.getByRole("dialog", { name: "Connect an integration" });26 headers: { "access-control-allow-origin": "*" },
22 await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog);27 json: {
23 await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos");28 results: [
24 await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).waitFor();29 {
25 await dialog.getByRole("link", { name: /^Google Photos Picker\b/ }).waitFor();30 domain: "photos.google.com",
26 },31 name: "Google Photos Library",
27 );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 () => {71 await step("Add Photos Library in place; the Picker card is untouched", async () => {
30 const dialog = page.getByRole("dialog", { name: "Connect an integration" });72 await page
31 await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).click();73 .getByTestId("catalog-photos.google.com-openapi")
32 await page.waitForURL(/\/integrations\/add\/openapi/);74 .filter({ hasText: "Google Photos Library API" })
33 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();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 () => {97 await step("Adding the SECOND same-kind card keeps its own identity", async () => {
37 const url = new URL(page.url());98 // The regression this guards: selection re-derived the surface by
38 expect(url.searchParams.get("preset")).toBe("google-photos-library");99 // KIND, so the second product of a kind inherited the first's slug,
39 expect(url.searchParams.get("url")).toContain("photoslibrary");100 // auth, and overrides.
40 await expect.poll(() => page.locator("textarea").inputValue()).toContain("photoslibrary");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 }),
e2e/scenarios/integrations-catalog-search.test.ts+103−64
@@ -1,77 +1,116 @@
1import { randomBytes } from "node:crypto";
2
1import { expect } from "@effect/vitest";3import { expect } from "@effect/vitest";
2import { Effect } from "effect";4import { Effect } from "effect";
5import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
3 6
4import { scenario } from "../src/scenario";7import { scenario } from "../src/scenario";
5import { Browser, Target } from "../src/services";8import { Browser, Target } from "../src/services";
6import { clickToReveal, visit } from "../src/surfaces/browser";9import { visit } from "../src/surfaces/browser";
7 10
8// The connect dialog's long-tail search goes to the public integrations.sh11// The picker's registry search goes to the public integrations.sh service from
9// registry from the browser. CI must not depend on the live service, so both12// the browser. CI must not depend on the live service, so the search endpoint
10// registry endpoints are fulfilled at the network layer here — including the13// is fulfilled at the network layer here — including the CORS header a real
11// CORS header a real cross-origin browser call needs.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.
12scenario(17scenario(
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* () {20 Effect.scoped(
16 const target = yield* Target;21 Effect.gen(function* () {
17 const browser = yield* Browser;22 const target = yield* Target;
18 const identity = yield* target.newIdentity();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 }) => {32 yield* browser.session(identity, async ({ page, step }) => {
21 await step("Stub the integrations.sh registry endpoints", async () => {33 await step("Stub the integrations.sh search endpoint", async () => {
22 await page.route("https://integrations.sh/api/search*", (route) =>34 await page.route("https://integrations.sh/api/search*", (route) =>
23 route.fulfill({35 route.fulfill({
24 contentType: "application/json",36 contentType: "application/json",
25 headers: { "access-control-allow-origin": "*" },37 headers: { "access-control-allow-origin": "*" },
26 json: {38 json: {
27 results: [39 results: [
28 {40 {
29 domain: "todoist.com",41 domain: "todoist.com",
30 name: "todoist.com",42 name: "todoist.com",
31 description: "Tasks, projects, and collaboration.",43 description: "Tasks, projects, and collaboration.",
32 kinds: ["mcp", "cli"],44 kinds: ["mcp", "cli"],
33 url: "https://integrations.sh/todoist.com/",45 surfaces: [
34 },46 { kind: "mcp", slug: `todoist-${suffix}`, url: server.endpoint },
35 ],47 { kind: "cli", slug: "todoist-cli" },
36 },48 ],
37 }),49 },
38 );50 {
39 await page.route("https://integrations.sh/api/todoist.com/surface", (route) =>51 domain: "deadserver.com",
40 route.fulfill({52 name: "deadserver.com",
41 contentType: "application/json",53 description: "A vendor whose MCP endpoint is gone.",
42 headers: { "access-control-allow-origin": "*" },54 kinds: ["mcp"],
43 json: {55 surfaces: [
44 version: 3,56 { kind: "mcp", slug: "deadserver", url: "https://mcp.notareal.invalid/mcp" },
45 domain: "todoist.com",57 ],
46 surfaces: [58 },
47 { type: "mcp", url: "https://ai.todoist.net/mcp", slug: "todoist" },59 ],
48 { type: "cli", slug: "todoist-cli" },60 },
49 ],61 }),
50 },62 );
51 }),63 });
52 );
53 });
54 64
55 await step("Searching surfaces the catalog row under the presets", async () => {65 await step("Searching surfaces the connectable rows, not the CLI", async () => {
56 await visit(page, "/integrations");66 await visit(page, "/integrations/browse");
57 const dialog = page.getByRole("dialog", { name: "Connect an integration" });67 await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist");
58 await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog);68 await page.getByTestId(`catalog-todoist-${suffix}`).getByText("Todoist MCP").waitFor();
59 await dialog.getByPlaceholder(/Search or paste a URL/).fill("todoist");69 await page.getByTestId("catalog-deadserver").waitFor();
60 // The CLI-only surface is not offered; the connectable kind is.70 expect(await page.getByTestId("catalog-todoist-cli").count()).toBe(0);
61 await dialog71 });
62 .getByRole("button", { name: /todoist\.com/ })72
63 .getByText("MCP")73 await step("Add registers in place; the card flips to View", async () => {
64 .waitFor();74 await page
65 });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 () => {100 await step("An unreachable endpoint falls back to the config screen", async () => {
68 const dialog = page.getByRole("dialog", { name: "Connect an integration" });101 await page
69 await dialog.getByRole("button", { name: /todoist\.com/ }).click();102 .getByTestId("catalog-deadserver")
70 await page.waitForURL(/\/integrations\/add\/mcp/);103 .getByRole("button", { name: "Add Deadserver MCP" })
71 const url = new URL(page.url());104 .click();
72 expect(url.searchParams.get("url")).toBe("https://ai.todoist.net/mcp");105 // Quick add cannot register it, so the click lands on the add flow
73 expect(url.searchParams.get("namespace")).toBe("todoist");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 });114 }),
76 }),115 ),
77);116);
e2e/scenarios/mcp-catalog-sync-ui.test.ts+1−1
@@ -40,7 +40,7 @@ scenario( · 39 unmodified lines
40 // The URL auto-probes (debounced); the method list appears once the40 // The URL auto-probes (debounced); the method list appears once the
41 // probe lands — an open server seeds the detected no-auth method.41 // probe lands — an open server seeds the detected no-auth method.
42 await page.getByText("How does this server authenticate?").waitFor();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 await step("Add the integration", async () => {46 await step("Add the integration", async () => {
e2e/scenarios/provider-plugins-ui.test.ts+85−46
@@ -3,10 +3,16 @@ import { Effect } from "effect"; · 2 unmodified lines
3 3
4import { scenario } from "../src/scenario";4import { scenario } from "../src/scenario";
5import { Browser, Target } from "../src/services";5import { Browser, Target } from "../src/services";
6import { clickToReveal, visit } from "../src/surfaces/browser";6import { visit } from "../src/surfaces/browser";
7 7
8const gmailSpecUrl = "https://integrations.sh/specs/google/google-gmail.json";
9const 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.
8scenario(14scenario(
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 Effect.gen(function* () {17 Effect.gen(function* () {
12 const target = yield* Target;18 const target = yield* Target;
@@ -14,58 +20,91 @@ scenario( · 1 unmodified line
14 const identity = yield* target.newIdentity();20 const identity = yield* target.newIdentity();
15 21
16 yield* browser.session(identity, async ({ page, step }) => {22 yield* browser.session(identity, async ({ page, step }) => {
17 await step("Open the integrations picker from the sidebar", async () => {23 await step("Stub the Google and Microsoft registry products", async () => {
18 await visit(page, "/policies");24 await page.route("https://integrations.sh/api/search*", (route) =>
19 await clickToReveal(25 route.fulfill({
20 page26 contentType: "application/json",
21 .getByRole("navigation")27 headers: { "access-control-allow-origin": "*" },
22 .getByRole("button", { name: "Browse integrations", exact: true }),28 json: {
23 page.getByRole("dialog", { name: "Connect an integration" }),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 () => {72 await step("Search for Gmail and see its registry card and domain", async () => {
28 const dialog = page.getByRole("dialog", {73 await visit(page, "/integrations/browse");
29 name: "Connect an integration",74 const search = page.getByPlaceholder(/Search integrations, or paste a URL/);
30 });
31 const search = dialog.getByPlaceholder(/Search or paste a URL/);
32 await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor();
33
34 await search.fill("gmail");75 await search.fill("gmail");
35 await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor();76 const card = page.getByTestId("catalog-gmail.com-openapi");
36 77 await card.waitFor();
37 await search.fill("onedrive");78 await card.getByText("gmail.com").waitFor();
38 await dialog.getByRole("link", { name: /^OneDrive Files\b/ }).waitFor();
39 });79 });
40 80
41 await step("OpenAPI add remains generic", async () => {81 await step("Add Gmail in place: the card flips to View", async () => {
42 await page.goto("/integrations/add/openapi", {82 await page
43 waitUntil: "domcontentloaded",83 .getByTestId("catalog-gmail.com-openapi")
44 });84 .getByRole("button", { name: "Add Gmail API" })
45 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();85 .click();
46 await page.getByText("OpenAPI Spec").waitFor();86 // The quick add fetches the hosted spec server-side and registers —
47 expect(await page.getByText("Customize your Google connection").count()).toBe(0);87 // no navigation. Generous timeout: a real spec fetch + parse rides
48 expect(await page.getByText("Customize Microsoft Graph").count()).toBe(0);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 () => {102 await step("Search for Outlook and see its separate registry card and domain", async () => {
52 await page.goto(103 await visit(page, "/integrations/browse");
53 "/integrations/add/openapi?preset=google-gmail&url=https%3A%2F%2Fwww.googleapis.com%2Fdiscovery%2Fv1%2Fapis%2Fgmail%2Fv1%2Frest",104 await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("outlook");
54 { waitUntil: "domcontentloaded" },105 const card = page.getByTestId("catalog-graph.microsoft.com-openapi");
55 );106 await card.waitFor();
56 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();107 await card.getByText("graph.microsoft.com").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");
69 });108 });
70 });109 });
71 }),110 }),
e2e/selfhost/auth-methods-ui.test.ts+1−1
@@ -169,7 +169,7 @@ scenario( · 168 unmodified lines
169 });169 });
170 170
171 await step("The probe detected OAuth", async () => {171 await step("The probe detected OAuth", async () => {
172 await page.getByText("Method 1 · Detected").waitFor();172 await page.getByText("OAuth · Detected").waitFor();
173 // The OAuth editor declares discovery-at-connect, not pasted URLs.173 // The OAuth editor declares discovery-at-connect, not pasted URLs.
174 await page.getByText("OAuth metadata is discovered from this server").waitFor();174 await page.getByText("OAuth metadata is discovered from this server").waitFor();
175 });175 });
e2e/selfhost/detected-auth-immutable-ui.test.ts+15−14
@@ -1,6 +1,6 @@
1// Selfhost-only (browser): a spec/probe-DETECTED auth method is immutable in1// Selfhost-only (browser): a spec/probe-DETECTED auth method is immutable in
2// the add flow. The shared AuthMethodListEditor renders detected methods as a2// the add flow. The shared AuthMethodListEditor renders detected methods as a
3// disabled, read-only summary ("Pulled from spec. Remove to override.") with no3// disabled, read-only summary (declared-by-this-API footer) with no
4// kind selector, so a user can't silently retype the spec's method into a kind4// kind selector, so a user can't silently retype the spec's method into a kind
5// nothing backs. A method the user adds by hand stays fully editable. Both the5// nothing backs. A method the user adds by hand stays fully editable. Both the
6// MCP and OpenAPI add flows compose the same editor, so one behavior, two6// 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
18import { Browser, Target } from "../src/services";18import { Browser, Target } from "../src/services";
19import { visit } from "../src/surfaces/browser";19import { visit } from "../src/surfaces/browser";
20 20
21const 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.
23const REMOVE_HINT = "Declared by this API.";
22 24
23scenario(25scenario(
24 "Detected auth · an MCP probe's OAuth method is immutable in the add flow",26 "Detected auth · an MCP probe's OAuth method is immutable in the add flow",
@@ -39,20 +41,16 @@ scenario( · 14 unmodified lines
39 await step("Open the add-MCP flow pointed at the OAuth server", async () => {41 await step("Open the add-MCP flow pointed at the OAuth server", async () => {
40 await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`);42 await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`);
41 await page.getByText("How does this server authenticate?").waitFor();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 await step("The detected method is locked: read-only, named, no selector", async () => {47 await step("The detected method is locked: read-only, named, no selector", async () => {
46 // The kind is named explicitly ("OAuth"), the discovered-OAuth summary48 // The kind is named in the row TITLE ("OAuth · Detected", asserted
47 // and override hint sit inside a disabled block, and there is NO49 // above), the discovered-OAuth summary and override hint render
48 // editable kind selector (the FilterTabs render as buttons).50 // read-only, and there is NO editable kind selector (the FilterTabs
49 await page.getByText("OAuth", { exact: true }).first().waitFor();51 // render as buttons).
50 await page.getByText("OAuth metadata is discovered from this server").waitFor();52 await page.getByText("OAuth metadata is discovered from this server").waitFor();
51 await page.getByText(REMOVE_HINT).waitFor();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 expect(54 expect(
57 await page.getByRole("button", { name: "API key", exact: true }).count(),55 await page.getByRole("button", { name: "API key", exact: true }).count(),
58 "no editable kind selector is shown for the detected method",56 "no editable kind selector is shown for the detected method",
@@ -150,7 +148,9 @@ scenario( · 91 unmodified lines
150 .first()148 .first()
151 .fill(spec.url);149 .fill(spec.url);
152 await page.getByText("How does this API authenticate?").waitFor();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 await step("Both detected methods are locked, named, read-only", async () => {156 await step("Both detected methods are locked, named, read-only", async () => {
@@ -162,8 +162,9 @@ scenario( · 5 unmodified lines
162 "both detected methods show the remove-to-override hint",162 "both detected methods show the remove-to-override hint",
163 ).toBe(2);163 ).toBe(2);
164 await page.getByText("https://api.acme.test/oauth/authorize").waitFor();164 await page.getByText("https://api.acme.test/oauth/authorize").waitFor();
165 await page.getByText("API key", { exact: true }).first().waitFor();165 // Kinds are named in the row TITLES ("API key · …" / "OAuth · …").
166 await page.getByText("OAuth", { exact: true }).first().waitFor();166 await page.getByText("API key · ").first().waitFor();
167 await page.getByText("OAuth · ").first().waitFor();
167 expect(168 expect(
168 await page.getByRole("button", { name: "OAuth", exact: true }).count(),169 await page.getByRole("button", { name: "OAuth", exact: true }).count(),
169 "no editable kind selector is shown for the detected methods",170 "no editable kind selector is shown for the detected methods",
e2e/selfhost/mcp-auth-required-add.test.ts+1−1
@@ -61,7 +61,7 @@ scenario( · 60 unmodified lines
61 });61 });
62 62
63 await step("The probe seeded a detected Bearer-header method", async () => {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 // The preview card flags the gate rather than failing the probe.65 // The preview card flags the gate rather than failing the probe.
66 await page.getByText("Auth required").first().waitFor();66 await page.getByText("Auth required").first().waitFor();
67 });67 });
e2e/selfhost/posthog-mcp-oauth.test.ts+1−1
@@ -51,7 +51,7 @@ scenario( · 50 unmodified lines
51 addUrl.searchParams.set("url", server.endpoint);51 addUrl.searchParams.set("url", server.endpoint);
52 await visit(page, addUrl.toString());52 await visit(page, addUrl.toString());
53 await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 });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 await page.getByText("OAuth metadata is discovered from this server").waitFor();55 await page.getByText("OAuth metadata is discovered from this server").waitFor();
56 });56 });
57 57
package.json+1−0
@@ -24,6 +24,7 @@ · 23 unmodified lines
24 "packages/*/*",24 "packages/*/*",
25 "packages/react",25 "packages/react",
26 "packages/app",26 "packages/app",
27 "packages/onboarding-demo",
27 "apps/*",28 "apps/*",
28 "examples/*",29 "examples/*",
29 "e2e"30 "e2e"
packages/app/src/routeTree.gen.ts+23−0
@@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './ · 16 unmodified lines
17import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts'17import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts'
18import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'18import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'
19import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'19import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'
20import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../react/src/routes/integrations.browse'
20import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'21import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'
21import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug'22import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug'
22import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId'23import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId'
@@ -70,6 +71,12 @@ const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute = · 47 unmodified lines
70 path: '/{-$orgSlug}/resume/$executionId',71 path: '/{-$orgSlug}/resume/$executionId',
71 getParentRoute: () => rootRouteImport,72 getParentRoute: () => rootRouteImport,
72 } as any)73 } as any)
74const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute =
75 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport.update({
76 id: '/{-$orgSlug}/integrations/browse',
77 path: '/{-$orgSlug}/integrations/browse',
78 getParentRoute: () => rootRouteImport,
79 } as any)
73const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute =80const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute =
74 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport.update({81 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport.update({
75 id: '/{-$orgSlug}/integrations/$namespace',82 id: '/{-$orgSlug}/integrations/$namespace',
@@ -113,6 +120,7 @@ export interface FileRoutesByFullPath { · 37 unmodified lines
113 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute120 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute
114 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute121 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
115 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute122 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
123 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
116 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute124 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
117 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute125 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
118 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute126 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -128,6 +136,7 @@ export interface FileRoutesByTo { · 9 unmodified lines
128 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute136 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute
129 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute137 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
130 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute138 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
139 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
131 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute140 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
132 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute141 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
133 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute142 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -144,6 +153,7 @@ export interface FileRoutesById { · 10 unmodified lines
144 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute153 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute
145 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute154 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
146 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute155 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
156 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
147 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute157 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
148 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute158 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
149 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute159 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -161,6 +171,7 @@ export interface FileRouteTypes { · 11 unmodified lines
161 | '/{-$orgSlug}/artifacts/$artifactId'171 | '/{-$orgSlug}/artifacts/$artifactId'
162 | '/{-$orgSlug}/connect/$integrationSlug'172 | '/{-$orgSlug}/connect/$integrationSlug'
163 | '/{-$orgSlug}/integrations/$namespace'173 | '/{-$orgSlug}/integrations/$namespace'
174 | '/{-$orgSlug}/integrations/browse'
164 | '/{-$orgSlug}/resume/$executionId'175 | '/{-$orgSlug}/resume/$executionId'
165 | '/{-$orgSlug}/toolkits/$toolkitSlug'176 | '/{-$orgSlug}/toolkits/$toolkitSlug'
166 | '/{-$orgSlug}/integrations/add/$pluginKey'177 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -176,6 +187,7 @@ export interface FileRouteTypes { · 9 unmodified lines
176 | '/{-$orgSlug}/artifacts/$artifactId'187 | '/{-$orgSlug}/artifacts/$artifactId'
177 | '/{-$orgSlug}/connect/$integrationSlug'188 | '/{-$orgSlug}/connect/$integrationSlug'
178 | '/{-$orgSlug}/integrations/$namespace'189 | '/{-$orgSlug}/integrations/$namespace'
190 | '/{-$orgSlug}/integrations/browse'
179 | '/{-$orgSlug}/resume/$executionId'191 | '/{-$orgSlug}/resume/$executionId'
180 | '/{-$orgSlug}/toolkits/$toolkitSlug'192 | '/{-$orgSlug}/toolkits/$toolkitSlug'
181 | '/{-$orgSlug}/integrations/add/$pluginKey'193 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -191,6 +203,7 @@ export interface FileRouteTypes { · 9 unmodified lines
191 | '/{-$orgSlug}/artifacts/$artifactId'203 | '/{-$orgSlug}/artifacts/$artifactId'
192 | '/{-$orgSlug}/connect/$integrationSlug'204 | '/{-$orgSlug}/connect/$integrationSlug'
193 | '/{-$orgSlug}/integrations/$namespace'205 | '/{-$orgSlug}/integrations/$namespace'
206 | '/{-$orgSlug}/integrations/browse'
194 | '/{-$orgSlug}/resume/$executionId'207 | '/{-$orgSlug}/resume/$executionId'
195 | '/{-$orgSlug}/toolkits/$toolkitSlug'208 | '/{-$orgSlug}/toolkits/$toolkitSlug'
196 | '/{-$orgSlug}/integrations/add/$pluginKey'209 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -206,6 +219,7 @@ export interface RootRouteChildren { · 9 unmodified lines
206 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute219 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
207 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute220 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
208 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute221 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
222 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
209 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute223 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
210 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute224 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
211 DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute225 DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute
@@ -269,6 +283,13 @@ declare module '@tanstack/react-router' { · 57 unmodified lines
269 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport283 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport
270 parentRoute: typeof rootRouteImport284 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 '/{-$orgSlug}/integrations/$namespace': {293 '/{-$orgSlug}/integrations/$namespace': {
273 id: '/{-$orgSlug}/integrations/$namespace'294 id: '/{-$orgSlug}/integrations/$namespace'
274 path: '/{-$orgSlug}/integrations/$namespace'295 path: '/{-$orgSlug}/integrations/$namespace'
@@ -353,6 +374,8 @@ const rootRouteChildren: RootRouteChildren = { · 78 unmodified lines
353 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute,374 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute,
354 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute:375 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute:
355 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute,376 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute,
377 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute:
378 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute,
356 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute:379 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute:
357 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute,380 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute,
358 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:381 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:
packages/app/src/web/shell.tsx+6−0
@@ -136,6 +136,12 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { · 135 unmodified lines
136 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",136 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
137 ].join(" ")}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 <IntegrationIconWithAccount145 <IntegrationIconWithAccount
140 icon={integrationPresetIconUrl(146 icon={integrationPresetIconUrl(
141 { id: slug, kind: integration.kind, name, url: integration.displayUrl },147 { id: slug, kind: integration.kind, name, url: integration.displayUrl },
packages/core/sdk/src/client.ts+60−0
@@ -124,6 +124,12 @@ export interface IntegrationPreset { · 123 unmodified lines
124 readonly specOverrides?: readonly unknown[];124 readonly specOverrides?: readonly unknown[];
125 readonly authTemplate?: readonly IntegrationPresetAuthentication[];125 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
126 readonly healthCheck?: HealthCheckSpec;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
129export type IntegrationPresetAuthentication =135export type IntegrationPresetAuthentication =
@@ -199,6 +205,34 @@ export interface EditSheetSectionProps { · 69 unmodified lines
199 readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void;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. */
210export 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
229export 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
202export interface IntegrationPlugin {236export interface IntegrationPlugin {
203 /** Unique key matching the SDK plugin id (e.g. "openapi"). */237 /** Unique key matching the SDK plugin id (e.g. "openapi"). */
204 readonly key: string;238 readonly key: string;
@@ -213,6 +247,21 @@ export interface IntegrationPlugin { · 8 unmodified lines
213 readonly initialUrl?: string;247 readonly initialUrl?: string;
214 readonly initialPreset?: string;248 readonly initialPreset?: string;
215 readonly initialNamespace?: string;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 /** Legacy full-page edit surface. No host renders this anymore — plugin266 /** Legacy full-page edit surface. No host renders this anymore — plugin
218 * configuration lives in the integration Edit sheet via `editSheet`. */267 * configuration lives in the integration Edit sheet via `editSheet`. */
@@ -245,6 +294,17 @@ export interface IntegrationPlugin { · 26 unmodified lines
245 * Call from the host on intent (hover/focus) so the chunks land before the294 * Call from the host on intent (hover/focus) so the chunks land before the
246 * user navigates into the add page. Idempotent. */295 * user navigates into the add page. Idempotent. */
247 readonly preload?: () => void;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// ---------------------------------------------------------------------------
packages/core/sdk/src/plugin.ts+4−0
@@ -612,6 +612,10 @@ export interface IntegrationPreset { · 611 unmodified lines
612 readonly specOverrides?: readonly unknown[];612 readonly specOverrides?: readonly unknown[];
613 readonly authTemplate?: readonly IntegrationPresetAuthentication[];613 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
614 readonly healthCheck?: HealthCheckSpec;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 readonly transport?: "remote" | "stdio";619 readonly transport?: "remote" | "stdio";
616 readonly command?: string;620 readonly command?: string;
617 readonly args?: readonly string[];621 readonly args?: readonly string[];
packages/onboarding-demo/CHANGELOG.md+1−0
@@ -0,0 +1 @@
1# @executor-js/onboarding-demo
packages/onboarding-demo/README.md+53−0
@@ -0,0 +1,53 @@
1# Onboarding demo
2
3A 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
5auth, no tenant — it runs off `src/fixtures.ts` and the public integrations.sh
6endpoints.
7
8```
9bun run --cwd packages/onboarding-demo dev # http://localhost:5199
10```
11
12Every screen is addressable: `#integrations-empty`, `#connect-dialog`,
13`#add-openapi`, `#detail-accounts`, `#add-account-credential`,
14`#add-account-place`, `#oauth-stuck`, `#integrations-populated`. `[` and `]`
15step through them. `src/flow.ts` records, per screen, the route and source
16component it was reproduced from plus the first-run reactions against it.
17
18A floating Current / Reworked control in the bottom-right corner switches
19between 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
25navigation within the picker; the console sidebar stays as it is today. A deep link to a domain you have not added in this session
26says so rather than rendering an empty screen — the prototype keeps no state
27across reloads.
28
29Registry picks and custom URLs converge: both become an added integration with
30one `default` account reading Needs auth. Only the detail header differs, naming
31where 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
46What is faked: the workspace's own data (integrations, connections, tools) and
47every mutation. Adding an integration waits and moves on; nothing persists.
48
49`fixtures.ts` also exposes two integrations.sh endpoints the console does **not**
50use today — `fetchCatalogDomains` (the whole ~3.4k-domain popularity-sorted
51registry) and `fetchSurfaceCredentials` (per-domain credential label, the URL
52that mints the key, and setup instructions). They are unused by the
53reproduction and present as raw material for the rework.
packages/onboarding-demo/index.html+18−0
@@ -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>
packages/onboarding-demo/package.json+32−0
@@ -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}
packages/onboarding-demo/src/app.tsx+202−0
@@ -0,0 +1,202 @@
1import { useCallback, useEffect, useMemo, useState } from "react";
2import { cn } from "@executor-js/react/lib/utils";
3import { Shell } from "./shell";
4import { IntegrationsPage } from "./screens/integrations-page";
5import { ConnectDialog } from "./screens/connect-dialog";
6import { AddOpenApiPage } from "./screens/add-openapi-page";
7import { IntegrationDetailPage } from "./screens/integration-detail";
8import { AddAccountModal } from "./screens/add-account-modal";
9import { flowSteps, type StepId } from "./flow";
10import { stepState, type AccountModal, type ScreenView } from "./step-state";
11import { NextFlow } from "./next-flow";
12import {
13 gmailAuthMethods,
14 gmailIntegration,
15 posthogAuthMethods,
16 posthogIntegration,
17 seededIntegrations,
18 type DemoIntegration,
19 type DemoPreset,
20} from "./fixtures";
21
22const 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
32const stepFromHash = (): StepId => {
33 const hash = globalThis.location?.hash.replace(/^#/, "") ?? "";
34 return flowSteps.some((step) => step.id === hash) ? (hash as StepId) : "integrations-empty";
35};
36
37function 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
148type Mode = "current" | "next";
149
150function 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
175const modeFromHash = (): Mode => {
176 const hash = globalThis.location?.hash ?? "";
177 return hash.startsWith("#reworked") || hash.startsWith("#next") ? "next" : "current";
178};
179
180export 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}
packages/onboarding-demo/src/catalog.ts+163−0
@@ -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
16import { fetchCatalogDomains, type CatalogDomain } from "./fixtures";
17
18export 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
30export 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
43const 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. */
60const 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. */
71const 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
86export 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`. */
99const displayName = (domain: string): string => {
100 const stem = domain.split(".")[0] ?? domain;
101 return stem.charAt(0).toUpperCase() + stem.slice(1);
102};
103
104export 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
121export 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
141export 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
152export 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. */
163export const logoFor = (domain: string): string => `https://integrations.sh/logo/${domain}`;
packages/onboarding-demo/src/fixtures.ts+240−0
@@ -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
11import { openApiPresets, type OpenApiPreset } from "@executor-js/plugin-openapi/presets";
12import { mcpPresets, type McpPreset } from "@executor-js/plugin-mcp/presets";
13
14export type PluginKey = "openapi" | "mcp" | "graphql" | "google";
15
16export 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
26const 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
36const 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. */
48export const curatedPresets: readonly DemoPreset[] = [
49 ...openApiPresets.map(fromOpenApi),
50 ...mcpPresets.map(fromMcp),
51];
52
53/** The plugin chips under "Or add manually". */
54export 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
65export interface DemoIntegration {
66 readonly slug: string;
67 readonly name: string;
68 readonly kind: PluginKey;
69 readonly icon?: string;
70 readonly toolCount: number;
71}
72
73export 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. */
77export 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. */
86export 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
103export interface DemoPlacement {
104 readonly carrier: "header" | "query" | "env";
105 readonly name: string;
106 readonly prefix: string;
107}
108
109export interface DemoAuthMethod {
110 readonly id: string;
111 readonly label: string;
112 readonly kind: "apikey" | "oauth" | "none";
113 readonly placements: readonly DemoPlacement[];
114}
115
116export 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
132export const gmailAuthMethods: readonly DemoAuthMethod[] = [
133 { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] },
134];
135
136// ---------------------------------------------------------------------------
137// integrations.sh — the live public catalog
138// ---------------------------------------------------------------------------
139
140export const INTEGRATIONS_SH_ORIGIN = "https://integrations.sh";
141
142export const CONNECTABLE_KINDS = ["mcp", "openapi", "graphql"] as const;
143export type CatalogKind = (typeof CONNECTABLE_KINDS)[number];
144
145export interface CatalogEntry {
146 readonly domain: string;
147 readonly description: string;
148 readonly kinds: readonly CatalogKind[];
149}
150
151export const catalogLogoUrl = (domain: string, size: number): string =>
152 `${INTEGRATIONS_SH_ORIGIN}/logo/${domain}?sz=${size * 2}`;
153
154const 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. */
159export 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
184export 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. */
195export 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
202export 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. */
213export 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};
packages/onboarding-demo/src/flow.ts+111−0
@@ -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
9export 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
19export 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
30export 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
111export const stepIndex = (id: StepId): number => flowSteps.findIndex((step) => step.id === id);
packages/onboarding-demo/src/main.tsx+12−0
@@ -0,0 +1,12 @@
1import { StrictMode } from "react";
2import { createRoot } from "react-dom/client";
3import { App } from "./app";
4import "./styles.css";
5
6const root = document.getElementById("root");
7if (root)
8 createRoot(root).render(
9 <StrictMode>
10 <App />
11 </StrictMode>,
12 );
packages/onboarding-demo/src/next-flow.tsx+304−0
@@ -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
13import { useCallback, useEffect, useState } from "react";
14import { Button } from "@executor-js/react/components/button";
15import { cn } from "@executor-js/react/lib/utils";
16import { Shell } from "./shell";
17import { BrowsePage } from "./screens/next/browse";
18import {
19 InstalledList,
20 NextIntegrationDetail,
21 type DemoAccount,
22} from "./screens/next/integration-detail";
23import { AuthenticateDialog } from "./screens/next/authenticate";
24import { AddCustomDialog } from "./screens/next/custom";
25import { fromCatalogItem, type AddedIntegration, type CatalogItem } from "./catalog";
26import type { DemoIntegration } from "./fixtures";
27
28interface Added {
29 readonly item: AddedIntegration;
30 readonly accounts: readonly DemoAccount[];
31}
32
33// ---------------------------------------------------------------------------
34// Routing
35// ---------------------------------------------------------------------------
36
37export 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
44const 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
58const 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
68const 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. */
73const 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
81export 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&apos;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}
packages/onboarding-demo/src/screens/add-account-modal.tsx+318−0
@@ -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
12import { useState } from "react";
13import { EyeIcon, EyeOffIcon, PlusIcon } from "lucide-react";
14import { Button } from "@executor-js/react/components/button";
15import { Input } from "@executor-js/react/components/input";
16import { Label } from "@executor-js/react/components/label";
17import {
18 Dialog,
19 DialogContent,
20 DialogDescription,
21 DialogFooter,
22 DialogHeader,
23 DialogTitle,
24} from "@executor-js/react/components/dialog";
25import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs";
26import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";
27import { cn } from "@executor-js/react/lib/utils";
28import type { DemoAuthMethod } from "../fixtures";
29
30function 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
41function 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
60export 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&apos;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}
packages/onboarding-demo/src/screens/add-openapi-page.tsx+261−0
@@ -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
11import { useState } from "react";
12import { LockIcon, XIcon, ChevronDownIcon } from "lucide-react";
13import { Button } from "@executor-js/react/components/button";
14import { Input } from "@executor-js/react/components/input";
15import { Label } from "@executor-js/react/components/label";
16import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";
17import type { DemoPreset } from "../fixtures";
18
19function 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
34function 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
42export 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&apos;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}
packages/onboarding-demo/src/screens/connect-dialog.tsx+279−0
@@ -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
9import { useEffect, useMemo, useRef, useState } from "react";
10import { Button } from "@executor-js/react/components/button";
11import { Input } from "@executor-js/react/components/input";
12import { Badge } from "@executor-js/react/components/badge";
13import { Skeleton } from "@executor-js/react/components/skeleton";
14import {
15 Dialog,
16 DialogContent,
17 DialogDescription,
18 DialogHeader,
19 DialogTitle,
20} from "@executor-js/react/components/dialog";
21import {
22 CardStack,
23 CardStackContent,
24 CardStackEntry,
25 CardStackEntryActions,
26 CardStackEntryContent,
27 CardStackEntryDescription,
28 CardStackEntryTitle,
29} from "@executor-js/react/components/card-stack";
30import {
31 catalogLogoUrl,
32 curatedPresets,
33 integrationPlugins,
34 searchCatalog,
35 type CatalogEntry,
36 type DemoPreset,
37 type PluginKey,
38} from "../fixtures";
39
40const CATALOG_KIND_LABEL: Record<string, string> = {
41 mcp: "MCP",
42 openapi: "OpenAPI",
43 graphql: "GraphQL",
44};
45
46const 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
55const SEARCH_DEBOUNCE_MS = 250;
56const MIN_QUERY_LENGTH = 2;
57
58function 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
85export 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}
packages/onboarding-demo/src/screens/integration-detail.tsx+105−0
@@ -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
9import { useState } from "react";
10import { Button } from "@executor-js/react/components/button";
11import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs";
12import type { DemoIntegration } from "../fixtures";
13
14export 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&apos;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&apos;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}
packages/onboarding-demo/src/screens/integrations-page.tsx+142−0
@@ -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
5import { PlusIcon } from "lucide-react";
6import { Button } from "@executor-js/react/components/button";
7import { PageContainer, PageHeader } from "@executor-js/react/components/page";
8import {
9 CardStack,
10 CardStackContent,
11 CardStackEntry,
12 CardStackEntryActions,
13 CardStackEntryContent,
14 CardStackEntryDescription,
15 CardStackEntryTitle,
16 CardStackHeader,
17} from "@executor-js/react/components/card-stack";
18import { Badge } from "@executor-js/react/components/badge";
19import { Tabs, TabsList, TabsTrigger } from "@executor-js/react/components/tabs";
20import type { DemoIntegration } from "../fixtures";
21
22function 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
63export 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}
packages/onboarding-demo/src/screens/next/authenticate.tsx+229−0
@@ -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
14import { useEffect, useState } from "react";
15import { ArrowUpRightIcon, EyeIcon, EyeOffIcon } from "lucide-react";
16import { Button } from "@executor-js/react/components/button";
17import { Label } from "@executor-js/react/components/label";
18import {
19 Dialog,
20 DialogContent,
21 DialogDescription,
22 DialogFooter,
23 DialogHeader,
24 DialogTitle,
25} from "@executor-js/react/components/dialog";
26import { 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. */
31function 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
71export 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}
packages/onboarding-demo/src/screens/next/browse.tsx+234−0
@@ -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
13import { useEffect, useMemo, useState } from "react";
14import { CheckIcon, PlusIcon, SearchIcon } from "lucide-react";
15import { Button } from "@executor-js/react/components/button";
16import { Input } from "@executor-js/react/components/input";
17import { Skeleton } from "@executor-js/react/components/skeleton";
18import { cn } from "@executor-js/react/lib/utils";
19import { CATEGORY_ORDER, loadCatalog, searchItems, type CatalogItem } from "../../catalog";
20
21const SECTION_SIZE = 6;
22
23function 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
39function 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. */
80function 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
102export 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}
packages/onboarding-demo/src/screens/next/custom.tsx+242−0
@@ -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
12import { useState } from "react";
13import { ArrowUpRightIcon } from "lucide-react";
14import { Button } from "@executor-js/react/components/button";
15import { Label } from "@executor-js/react/components/label";
16import {
17 Dialog,
18 DialogContent,
19 DialogDescription,
20 DialogFooter,
21 DialogHeader,
22 DialogTitle,
23} from "@executor-js/react/components/dialog";
24import { getDomain } from "tldts";
25import { logoFor, type AddedIntegration } from "../../catalog";
26import { INTEGRATIONS_SH_ORIGIN } from "../../fixtures";
27
28type Kind = "mcp" | "openapi" | "graphql";
29
30const KIND_LABEL: Record<Kind, string> = {
31 mcp: "MCP server",
32 openapi: "OpenAPI spec",
33 graphql: "GraphQL endpoint",
34};
35
36interface 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
50const 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
60const 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
105export 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&apos;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}
packages/onboarding-demo/src/screens/next/integration-detail.tsx+209−0
@@ -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
13import { ArrowLeftIcon, ArrowUpRightIcon, ChevronDownIcon, PlusIcon } from "lucide-react";
14import { Button } from "@executor-js/react/components/button";
15import { cn } from "@executor-js/react/lib/utils";
16import type { AddedIntegration } from "../../catalog";
17
18export interface DemoAccount {
19 readonly label: string;
20 readonly status: "needs-auth" | "connected";
21 readonly identity?: string;
22}
23
24function 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
54export 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. */
146export 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}
packages/onboarding-demo/src/shell.tsx+97−0
@@ -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
5import type { ReactNode } from "react";
6import { cn } from "@executor-js/react/lib/utils";
7import type { DemoIntegration } from "./fixtures";
8
9const NAV_ITEMS = ["Integrations", "Secrets", "Policies", "Toolkits", "Artifacts"] as const;
10
11function 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
26export 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&apos;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}
packages/onboarding-demo/src/step-state.ts+45−0
@@ -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
5import type { StepId } from "./flow";
6import { gmailIntegration, posthogIntegration, type DemoIntegration } from "./fixtures";
7
8export type AccountModal = "none" | "credential" | "place" | "oauth-stuck";
9export type ScreenView = "integrations" | "add" | "detail";
10
11export interface StepState {
12 readonly integrations: readonly DemoIntegration[];
13 readonly connectOpen: boolean;
14 readonly accountModal: AccountModal;
15 readonly view: ScreenView;
16}
17
18const EMPTY: StepState = {
19 integrations: [],
20 connectOpen: false,
21 accountModal: "none",
22 view: "integrations",
23};
24
25const WITH_POSTHOG: StepState = {
26 integrations: [posthogIntegration],
27 connectOpen: false,
28 accountModal: "none",
29 view: "detail",
30};
31
32export 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};
packages/onboarding-demo/src/styles.css+5−0
@@ -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}";
packages/onboarding-demo/tsconfig.json+15−0
@@ -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}
packages/onboarding-demo/vite.config.ts+12−0
@@ -0,0 +1,12 @@
1import { defineConfig } from "vite";
2import react from "@vitejs/plugin-react";
3import 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.
9export default defineConfig({
10 plugins: [react(), tailwindcss()],
11 server: { port: 5199 },
12});
packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx+19−2
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
15 type AuthMethodRow,15 type AuthMethodRow,
16 type AuthMethodSeed,16 type AuthMethodSeed,
17} from "@executor-js/react/components/auth-method-list-editor";17} from "@executor-js/react/components/auth-method-list-editor";
18import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
18import { FloatActions } from "@executor-js/react/components/float-actions";19import { FloatActions } from "@executor-js/react/components/float-actions";
19import {20import {
20 addIntegrationErrorMessage,21 addIntegrationErrorMessage,
@@ -42,13 +43,25 @@ export default function AddGraphqlIntegration(props: { · 21 unmodified lines
42 onComplete: (slug?: string) => void;43 onComplete: (slug?: string) => void;
43 onCancel: () => void;44 onCancel: () => void;
44 initialUrl?: string;45 initialUrl?: string;
46 initialAuthHeader?: string;
47 initialAuthNote?: string;
45}) {48}) {
46 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");49 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");
47 const [description, setDescription] = useState("");50 const [description, setDescription] = useState("");
48 const identity = useIntegrationIdentity({51 const identity = useIntegrationIdentity({
49 fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "",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 const [adding, setAdding] = useState(false);65 const [adding, setAdding] = useState(false);
53 const [addError, setAddError] = useState<string | null>(null);66 const [addError, setAddError] = useState<string | null>(null);
54 67
@@ -144,9 +157,13 @@ export default function AddGraphqlIntegration(props: { · 89 unmodified lines
144 list={authMethodList}157 list={authMethodList}
145 allowedKinds={["none", "apikey"]}158 allowedKinds={["none", "apikey"]}
146 emptyHint="No authentication declared. Add a method, or add the integration without auth and connect an account from the integration page later."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 {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />}167 {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />}
151 168
152 {addError && <FormErrorAlert message={addError} />}169 {addError && <FormErrorAlert message={addError} />}
packages/plugins/graphql/src/react/integration-plugin.ts+47−3
@@ -1,6 +1,49 @@
1import { lazy } from "react";1import { lazy, useCallback } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import type {
5 IntegrationPlugin,
6 IntegrationQuickAddInput,
7 IntegrationQuickAddResult,
8} from "@executor-js/sdk/client";
9import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
10import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";
11import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
3import { graphqlPresets } from "../sdk/presets";12import { graphqlPresets } from "../sdk/presets";
13import { createGraphqlIntegrationOptimistic } from "./atoms";
14import { 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. */
20function 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
5const importAdd = () => import("./AddGraphqlIntegration");48const importAdd = () => import("./AddGraphqlIntegration");
6const importAccounts = () => import("./GraphqlAccountsPanel");49const importAccounts = () => import("./GraphqlAccountsPanel");
@@ -12,9 +55,10 @@ export const graphqlIntegrationPlugin: IntegrationPlugin = { · 5 unmodified lines
12 label: "GraphQL",55 label: "GraphQL",
13 add: lazy(importAdd),56 add: lazy(importAdd),
14 accounts: lazy(importAccounts),57 accounts: lazy(importAccounts),
15 presets: graphqlPresets,58 presets: graphqlPresets.map((preset) => ({ ...preset, registryListed: true })),
16 preload: () => {59 preload: () => {
17 void importAdd();60 void importAdd();
18 void importAccounts();61 void importAccounts();
19 },62 },
63 useQuickAdd: useGraphqlQuickAdd,
20};64};
packages/plugins/mcp/src/api/group.ts+11−0
@@ -36,6 +36,11 @@ const AddRemoteServerPayload = Schema.Struct({ · 35 unmodified lines
36 description: Schema.optional(Schema.String),36 description: Schema.optional(Schema.String),
37 endpoint: Schema.String,37 endpoint: Schema.String,
38 remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])),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 slug: Schema.optional(Schema.String),44 slug: Schema.optional(Schema.String),
40 queryParams: Schema.optional(StringMap),45 queryParams: Schema.optional(StringMap),
41 headers: Schema.optional(StringMap),46 headers: Schema.optional(StringMap),
@@ -100,6 +105,12 @@ const ProbeEndpointResponse = Schema.Struct({ · 58 unmodified lines
100 serverName: Schema.NullOr(Schema.String),105 serverName: Schema.NullOr(Schema.String),
101 /** Server `instructions` from initialize — prefills the description field. */106 /** Server `instructions` from initialize — prefills the description field. */
102 instructions: Schema.NullOr(Schema.String),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// ---------------------------------------------------------------------------
packages/plugins/mcp/src/api/handlers.test.ts+33−0
@@ -120,4 +120,37 @@ describe("McpHandlers", () => { · 119 unmodified lines
120 expect(body.message).toContain("Do you need to provide an API key");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});
packages/plugins/mcp/src/api/handlers.ts+2−0
@@ -71,6 +71,7 @@ const toServerInput = ( · 70 unmodified lines
71 description?: string;71 description?: string;
72 endpoint: string;72 endpoint: string;
73 remoteTransport?: "streamable-http" | "sse" | "auto";73 remoteTransport?: "streamable-http" | "sse" | "auto";
74 versionNegotiation?: "auto" | "legacy";
74 queryParams?: Record<string, string>;75 queryParams?: Record<string, string>;
75 headers?: Record<string, string>;76 headers?: Record<string, string>;
76 slug?: string;77 slug?: string;
@@ -87,6 +88,7 @@ const toServerInput = ( · 10 unmodified lines
87 description: p.description,88 description: p.description,
88 endpoint: p.endpoint,89 endpoint: p.endpoint,
89 remoteTransport: p.remoteTransport,90 remoteTransport: p.remoteTransport,
91 versionNegotiation: p.versionNegotiation,
90 queryParams: p.queryParams,92 queryParams: p.queryParams,
91 headers: p.headers,93 headers: p.headers,
92 slug: p.slug,94 slug: p.slug,
packages/plugins/mcp/src/react/AddMcpIntegration.tsx+32−25
@@ -37,10 +37,15 @@ import { · 36 unmodified lines
37import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";37import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
38import type { McpAuthMethodInput } from "../sdk/types";38import type { McpAuthMethodInput } from "../sdk/types";
39import { probeMcpEndpoint, addMcpServer } from "./atoms";39import { probeMcpEndpoint, addMcpServer } from "./atoms";
40import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
40import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields";41import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields";
41import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";42import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
42import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";43import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";
43import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";44import {
45 mcpAuthMethodInputFromEditorValue,
46 mcpDetectedAuthSeeds,
47 mcpWireAuthInput,
48} from "./auth-method-config";
44import CodexPluginAdd from "./CodexPluginAdd";49import CodexPluginAdd from "./CodexPluginAdd";
45import { parseStdioArgs } from "./stdio-fields";50import { parseStdioArgs } from "./stdio-fields";
46import { isProbableMcpEndpoint } from "./probe-url";51import { isProbableMcpEndpoint } from "./probe-url";
@@ -79,6 +84,8 @@ type ProbeResult = { · 32 unmodified lines
79 toolCount: number | null;84 toolCount: number | null;
80 serverName: string | null;85 serverName: string | null;
81 instructions: string | null;86 instructions: string | null;
87 /** "legacy" when only the legacy handshake worked (version-echoing server). */
88 versionNegotiation?: "auto" | "legacy";
82};89};
83 90
84type State =91type State =
@@ -161,6 +168,9 @@ export default function AddMcpIntegration(props: { · 76 unmodified lines
161 onCancel: () => void;168 onCancel: () => void;
162 initialUrl?: string;169 initialUrl?: string;
163 initialPreset?: string;170 initialPreset?: string;
171 initialAuthHeader?: string;
172 initialAuthNote?: string;
173 initialAuthKind?: string;
164 /** Whether the stdio transport is enabled on the server. */174 /** Whether the stdio transport is enabled on the server. */
165 allowStdio?: boolean;175 allowStdio?: boolean;
166}) {176}) {
@@ -219,29 +229,16 @@ export default function AddMcpIntegration(props: { · 52 unmodified lines
219 // The probe seeds the method list: detected OAuth → an OAuth row; a 401229 // The probe seeds the method list: detected OAuth → an OAuth row; a 401
220 // without OAuth metadata → a bearer-header row; an open server → a no-auth230 // without OAuth metadata → a bearer-header row; an open server → a no-auth
221 // row. The user can edit any row or add alternate methods alongside.231 // row. The user can edit any row or add alternate methods alongside.
222 const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => {232 const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(
223 if (!probe) return [];233 () =>
224 if (probe.requiresOAuth) {234 mcpDetectedAuthSeeds(probe, {
225 return [235 placement: props.initialAuthHeader
226 {236 ? placementFromHeaderPattern(props.initialAuthHeader)
227 value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] },237 : null,
228 label: "Detected",238 kind: props.initialAuthKind,
229 },239 }),
230 ];240 [probe, props.initialAuthHeader, props.initialAuthKind],
231 }241 );
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]);
245 const authMethodList = useAuthMethodList(authMethodSeeds);242 const authMethodList = useAuthMethodList(authMethodSeeds);
246 243
247 const remoteIdentity = useIntegrationIdentity({244 const remoteIdentity = useIntegrationIdentity({
@@ -337,6 +334,12 @@ export default function AddMcpIntegration(props: { · 89 unmodified lines
337 ...(slug ? { slug } : {}),334 ...(slug ? { slug } : {}),
338 ...(headers ? { headers } : {}),335 ...(headers ? { headers } : {}),
339 authenticationTemplate,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 reactivityKeys: integrationWriteKeys,344 reactivityKeys: integrationWriteKeys,
342 });345 });
@@ -496,10 +499,14 @@ export default function AddMcpIntegration(props: { · 153 unmodified lines
496 title="How does this server authenticate?"499 title="How does this server authenticate?"
497 oauthMetadata="discovered"500 oauthMetadata="discovered"
498 emptyHint="No methods declared. Add a method, or add the server without auth and connect from the integration page later."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 {/* Error (add server). Probe errors show inline on the field. */}510 {/* Error (add server). Probe errors show inline on the field. */}
504 {otherError && (511 {otherError && (
505 <div className="space-y-2">512 <div className="space-y-2">
packages/plugins/mcp/src/react/auth-method-config.ts+53−0
@@ -9,6 +9,7 @@ · 8 unmodified lines
9 9
10import { AuthTemplateSlug } from "@executor-js/sdk/shared";10import { AuthTemplateSlug } from "@executor-js/sdk/shared";
11import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";11import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
12import type { AuthMethodSeed } from "@executor-js/react/components/auth-method-list-editor";
12import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";13import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
13import {14import {
14 authMethodFromSharedTemplate,15 authMethodFromSharedTemplate,
@@ -126,3 +127,55 @@ export function mcpAuthMethodInputsFromPlacements( · 111 unmodified lines
126 if (wire.length === 0) return [];127 if (wire.length === 0) return [];
127 return [{ kind: "apikey", placements: wire }];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. */
136export 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}
packages/plugins/mcp/src/react/integration-plugin.tsx+66−3
@@ -1,6 +1,21 @@
1import { lazy, type ComponentProps, type ComponentType } from "react";1import { lazy, useCallback, type ComponentProps, type ComponentType } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import type {
5 IntegrationPlugin,
6 IntegrationQuickAddInput,
7 IntegrationQuickAddResult,
8} from "@executor-js/sdk/client";
9import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
10import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";
11import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
3import { mcpPresets } from "../sdk/presets";12import { mcpPresets } from "../sdk/presets";
13import { addMcpServer, probeMcpEndpoint } from "./atoms";
14import {
15 mcpAuthMethodInputFromEditorValue,
16 mcpDetectedAuthSeeds,
17 mcpWireAuthInput,
18} from "./auth-method-config";
4 19
5const importAdd = () => import("./AddMcpIntegration");20const importAdd = () => import("./AddMcpIntegration");
6const importEditSheet = () => import("./EditMcpIntegration");21const importEditSheet = () => import("./EditMcpIntegration");
@@ -12,6 +27,51 @@ const LazyMcpAccountsPanel = lazy(importAccounts); · 5 unmodified lines
12 27
13type AddProps = ComponentProps<IntegrationPlugin["add"]>;28type 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. */
35function 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
15export interface McpIntegrationPluginOptions {75export interface McpIntegrationPluginOptions {
16 /**76 /**
17 * Enable the stdio transport in the add-integration UI (tab + presets).77 * Enable the stdio transport in the add-integration UI (tab + presets).
@@ -32,11 +92,13 @@ export const createMcpIntegrationPlugin = ( · 14 unmodified lines
32 <LazyAddMcpIntegration {...props} allowStdio={allowStdio} />92 <LazyAddMcpIntegration {...props} allowStdio={allowStdio} />
33 );93 );
34 94
35 const presets = allowStdio95 const base = allowStdio
36 ? mcpPresets96 ? mcpPresets
37 : mcpPresets.filter(97 : mcpPresets.filter(
38 (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"),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 return {103 return {
42 key: "mcp",104 key: "mcp",
@@ -50,6 +112,7 @@ export const createMcpIntegrationPlugin = ( · 7 unmodified lines
50 void importEditSheet();112 void importEditSheet();
51 void importAccounts();113 void importAccounts();
52 },114 },
115 useQuickAdd: useMcpQuickAdd,
53 };116 };
54};117};
55 118
packages/plugins/mcp/src/sdk/connection.ts+6−5
@@ -634,13 +634,14 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 633 unmodified lines
634 634
635 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});635 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});
636 636
637 // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable637 // Auto-negotiate the 2026-07-28 era on Streamable HTTP unless the config
638 // HTTP. SSE is a legacy-only transport; stdio negotiates per the638 // pins `legacy` (for servers that echo the proposed revision and then
639 // integration's `versionNegotiation` (default legacy — see the stdio639 // violate its contract). SSE is a legacy-only transport; stdio negotiates
640 // branch above).640 // per the integration's `versionNegotiation` (default legacy — see the
641 // stdio branch above).
641 const connectStreamableHttp = connectClient({642 const connectStreamableHttp = connectClient({
642 transport: "streamable-http",643 transport: "streamable-http",
643 versionNegotiation: { mode: "auto" },644 ...(input.versionNegotiation === "legacy" ? {} : { versionNegotiation: { mode: "auto" } }),
644 createTransport: (sdk) =>645 createTransport: (sdk) =>
645 new sdk.client.StreamableHTTPClientTransport(endpoint, {646 new sdk.client.StreamableHTTPClientTransport(endpoint, {
646 requestInit,647 requestInit,
packages/plugins/mcp/src/sdk/discover.ts+55−4
@@ -2,10 +2,11 @@ · 1 unmodified line
2// MCP tool discovery — connect to an MCP server and list its tools2// MCP tool discovery — connect to an MCP server and list its tools
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import { Duration, Effect, Option, Predicate } from "effect";5import { Duration, Effect, Option, Predicate, Schema } from "effect";
6 6
7import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection";7import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection";
8import { McpToolDiscoveryError } from "./errors";8import { McpToolDiscoveryError } from "./errors";
9import { createMcpConnector, type ConnectorInput } from "./connection";
9import { httpStatusFromCause } from "./http-status";10import { httpStatusFromCause } from "./http-status";
10import {11import {
11 decodeListToolsPage,12 decodeListToolsPage,
@@ -34,6 +35,14 @@ const DEFAULT_DISCOVER_TIMEOUT = Duration.seconds(15); · 22 unmodified lines
34// Public API35// 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. */
40const SdkFailure = Schema.Struct({
41 code: Schema.optional(Schema.String),
42 message: Schema.optional(Schema.String),
43});
44const decodeSdkFailure = Schema.decodeUnknownOption(SdkFailure);
45
37/**46/**
38 * List every tool from an open MCP connection, following `nextCursor`47 * List every tool from an open MCP connection, following `nextCursor`
39 * pagination (spec: `tools/list` is a paginated operation — a single call48 * pagination (spec: `tools/list` is a paginated operation — a single call
@@ -68,14 +77,23 @@ const listAllTools = ( · 28 unmodified lines
68 reauthorizationRequired: true,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 const httpStatus = httpStatusFromCause(cause);86 const httpStatus = httpStatusFromCause(cause);
72 return new McpToolDiscoveryError({87 return new McpToolDiscoveryError({
73 stage: "list_tools",88 stage: "list_tools",
74 message:89 message:
75 httpStatus === undefined90 failure?.message !== undefined
76 ? "Failed listing MCP tools"91 ? `Failed listing MCP tools: ${failure.message.slice(0, 300)}`
77 : `Failed listing MCP tools (HTTP ${httpStatus})`,92 : httpStatus === undefined
93 ? "Failed listing MCP tools"
94 : `Failed listing MCP tools (HTTP ${httpStatus})`,
78 ...(httpStatus === undefined ? {} : { httpStatus }),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. */
129export 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 * Connect to an MCP server and discover all available tools.158 * Connect to an MCP server and discover all available tools.
108 * Returns the parsed manifest containing server metadata and tool entries.159 * Returns the parsed manifest containing server metadata and tool entries.
packages/plugins/mcp/src/sdk/errors.ts+5−0
@@ -43,6 +43,11 @@ export class McpToolDiscoveryError extends Schema.TaggedErrorClass<McpToolDiscov · 42 unmodified lines
43 /** HTTP status from the underlying connect or tools/list failure, when43 /** HTTP status from the underlying connect or tools/list failure, when
44 * known. */44 * known. */
45 httpStatus: Schema.optional(Schema.Number),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 /** The MCP OAuth provider reached the interactive authorization boundary.51 /** The MCP OAuth provider reached the interactive authorization boundary.
47 * Catalog callers use this structural signal to request reconnect without52 * Catalog callers use this structural signal to request reconnect without
48 * parsing or exposing an upstream error message. */53 * parsing or exposing an upstream error message. */
packages/plugins/mcp/src/sdk/plugin.ts+27−19
@@ -45,7 +45,7 @@ import { · 44 unmodified lines
45import type { CodexPluginEntry } from "./codex-plugins";45import type { CodexPluginEntry } from "./codex-plugins";
46import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection";46import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection";
47import { createMcpConnectionPool } from "./connection-pool";47import { createMcpConnectionPool } from "./connection-pool";
48import { discoverTools } from "./discover";48import { discoverToolsFromInput } from "./discover";
49import {49import {
50 McpConnectionError,50 McpConnectionError,
51 type McpConnectionFailureKind,51 type McpConnectionFailureKind,
@@ -218,6 +218,10 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 166 unmodified lines
218 /** Single-method shorthand (legacy callers). Ignored when218 /** Single-method shorthand (legacy callers). Ignored when
219 * `authenticationTemplate` is present. Defaults to none. */219 * `authenticationTemplate` is present. Defaults to none. */
220 auth: Schema.optional(McpAuthShorthand),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
223const McpStdioServerInputSchema = Schema.Struct({227const McpStdioServerInputSchema = Schema.Struct({
@@ -305,6 +309,10 @@ const McpProbeEndpointOutputSchema = Schema.Struct({ · 81 unmodified lines
305 /** The server's `instructions` from initialize — prefill for the add form's309 /** The server's `instructions` from initialize — prefill for the add form's
306 * description. Only available when the probe connected unauthenticated. */310 * description. Only available when the probe connected unauthenticated. */
307 instructions: Schema.NullOr(Schema.String),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 authenticationTemplate: input.authenticationTemplate462 authenticationTemplate: input.authenticationTemplate
455 ? normalizeMcpAuthMethods(input.authenticationTemplate)463 ? normalizeMcpAuthMethods(input.authenticationTemplate)
456 : [mcpAuthMethodFromShorthand(input.auth ?? { kind: "none" })],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 authProvider,704 authProvider,
696 ...(authProvider === undefined ? {} : { staticOAuthBearer: true }),705 ...(authProvider === undefined ? {} : { staticOAuthBearer: true }),
697 httpClientLayer,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 const probeHeaders = typeof input === "string" ? undefined : input.headers;967 const probeHeaders = typeof input === "string" ? undefined : input.headers;
958 const probeQueryParams = typeof input === "string" ? undefined : input.queryParams;968 const probeQueryParams = typeof input === "string" ? undefined : input.queryParams;
959 969
960 const connector = createMcpConnector({970 const result = yield* discoverToolsFromInput({
961 transport: "remote",971 transport: "remote",
962 endpoint: trimmed,972 endpoint: trimmed,
963 headers: probeHeaders,973 headers: probeHeaders,
964 queryParams: probeQueryParams,974 queryParams: probeQueryParams,
965 httpClientLayer,975 httpClientLayer,
966 });976 }).pipe(
967 977 Effect.map((d) => ({ ok: true as const, ...d })),
968 const result = yield* discoverTools(connector).pipe(978 Effect.catch(() =>
969 Effect.map((m) => ({ ok: true as const, manifest: m })),979 Effect.succeed({ ok: false as const, manifest: null, versionNegotiation: null }),
970 Effect.catch(() => Effect.succeed({ ok: false as const, manifest: null })),980 ),
971 Effect.withSpan("mcp.plugin.discover_tools"),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 toolCount: result.manifest.tools.length,992 toolCount: result.manifest.tools.length,
983 serverName: result.manifest.server?.name ?? null,993 serverName: result.manifest.server?.name ?? null,
984 instructions: result.manifest.server?.instructions ?? null,994 instructions: result.manifest.server?.instructions ?? null,
995 ...(result.versionNegotiation === "legacy"
996 ? { versionNegotiation: "legacy" as const }
997 : {}),
985 } satisfies McpProbeResult;998 } satisfies McpProbeResult;
986 }999 }
987 1000
@@ -1424,10 +1437,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 436 unmodified lines
1424 template === null ? null : String(template),1437 template === null ? null : String(template),
1425 allowStdio,1438 allowStdio,
1426 httpClientLayer,1439 httpClientLayer,
1427 ).pipe(1440 ).pipe(Effect.result);
1428 Effect.map((ci) => createMcpConnector(ci)),
1429 Effect.result,
1430 );
1431 1441
1432 if (Result.isFailure(built)) {1442 if (Result.isFailure(built)) {
1433 return {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 Effect.result,1451 Effect.result,
1442 Effect.withSpan("mcp.plugin.discover_tools", {1452 Effect.withSpan("mcp.plugin.discover_tools", {
1443 attributes: { "mcp.connection.name": String(connection.name) },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 }).pipe(1474 }).pipe(
1465 Effect.withSpan("mcp.plugin.resolve_tools", {1475 Effect.withSpan("mcp.plugin.resolve_tools", {
1466 attributes: { "mcp.connection.name": String(connection.name) },1476 attributes: { "mcp.connection.name": String(connection.name) },
@@ -1676,13 +1686,11 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 209 unmodified lines
1676 const name = parsed.value.hostname || "mcp";1686 const name = parsed.value.hostname || "mcp";
1677 const slug = deriveMcpNamespace({ endpoint: trimmed });1687 const slug = deriveMcpNamespace({ endpoint: trimmed });
1678 1688
1679 const connector = createMcpConnector({1689 const connected = yield* discoverToolsFromInput({
1680 transport: "remote",1690 transport: "remote",
1681 endpoint: trimmed,1691 endpoint: trimmed,
1682 httpClientLayer,1692 httpClientLayer,
1683 });1693 }).pipe(
1684
1685 const connected = yield* discoverTools(connector).pipe(
1686 Effect.map(() => true),1694 Effect.map(() => true),
1687 Effect.catch(() => Effect.succeed(false)),1695 Effect.catch(() => Effect.succeed(false)),
1688 Effect.withSpan("mcp.plugin.discover_tools"),1696 Effect.withSpan("mcp.plugin.discover_tools"),
@@ -1794,9 +1802,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 105 unmodified lines
1794 credential.template === null ? null : String(credential.template),1802 credential.template === null ? null : String(credential.template),
1795 allowStdio,1803 allowStdio,
1796 options?.httpClientLayer ?? ctx.httpClientLayer,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 Effect.map(1808 Effect.map(
1801 () =>1809 () =>
1802 ({ status: "healthy" as const, checkedAt: Date.now() }) satisfies HealthCheckResult,1810 ({ status: "healthy" as const, checkedAt: Date.now() }) satisfies HealthCheckResult,
packages/plugins/mcp/src/sdk/presets.ts+0−8
@@ -57,14 +57,6 @@ const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map( · 56 unmodified lines
57}));57}));
58 58
59export const mcpPresets: readonly McpPreset[] = [59export 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 id: "deepwiki",61 id: "deepwiki",
70 name: "DeepWiki",62 name: "DeepWiki",
packages/plugins/mcp/src/sdk/types.ts+6−0
@@ -231,6 +231,12 @@ export const McpRemoteIntegrationConfig = Schema.Struct({ · 230 unmodified lines
231 /** Declared auth methods — how a connection's values are rendered onto231 /** Declared auth methods — how a connection's values are rendered onto
232 * requests. A connection's `template` picks one by slug. */232 * requests. A connection's `template` picks one by slug. */
233 authenticationTemplate: Schema.Array(McpAuthMethod),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});
235export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type;241export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type;
236 242
packages/plugins/openapi/src/api/group.ts+3−0
@@ -82,6 +82,9 @@ const AddSpecPayload = Schema.Struct({ · 81 unmodified lines
82 name: Schema.optional(Schema.String),82 name: Schema.optional(Schema.String),
83 description: Schema.optional(Schema.String),83 description: Schema.optional(Schema.String),
84 baseUrl: Schema.optional(Schema.String),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 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),88 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
86 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),89 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
87 specFormat: Schema.optional(Schema.String),90 specFormat: Schema.optional(Schema.String),
packages/plugins/openapi/src/api/handlers.ts+1−0
@@ -63,6 +63,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 62 unmodified lines
63 name: payload.name,63 name: payload.name,
64 description: payload.description,64 description: payload.description,
65 baseUrl: payload.baseUrl,65 baseUrl: payload.baseUrl,
66 displayDomain: payload.displayDomain,
66 headers: payload.headers ? { ...payload.headers } : undefined,67 headers: payload.headers ? { ...payload.headers } : undefined,
67 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,68 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
68 specFormat: payload.specFormat,69 specFormat: payload.specFormat,
packages/plugins/openapi/src/providers/google/discovery.test.ts+28−0
@@ -1,5 +1,6 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import { Effect, Option, Schema } from "effect";2import { Effect, Option, Schema } from "effect";
3import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";4import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";
4 5
5import {6import {
@@ -7,6 +8,7 @@ import { · 1 unmodified line
7 convertGoogleDiscoveryToOpenApi,8 convertGoogleDiscoveryToOpenApi,
8 isGoogleDiscoveryUrl,9 isGoogleDiscoveryUrl,
9 normalizeGoogleDiscoveryUrl,10 normalizeGoogleDiscoveryUrl,
11 fetchGoogleDiscoveryDocument,
10} from "./discovery";12} from "./discovery";
11import { googleOAuthConsentScopesForPreset } from "./service-policy";13import { googleOAuthConsentScopesForPreset } from "./service-policy";
12import { extract, parse } from "@executor-js/plugin-openapi";14import { 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
1675it("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});
packages/plugins/openapi/src/providers/google/discovery.ts+62−28
@@ -343,6 +343,25 @@ export const isGoogleDiscoveryUrl = (url: string): boolean => { · 342 unmodified lines
343 return normalizeGoogleDiscoveryUrl(url) !== null;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. */
350const 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
346export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(365export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(
347 function* (discoveryUrl: string, credentials?: SpecFetchCredentials) {366 function* (discoveryUrl: string, credentials?: SpecFetchCredentials) {
348 const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl);367 const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl);
@@ -353,35 +372,50 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov · 4 unmodified lines
353 });372 });
354 }373 }
355 const client = yield* HttpClient.HttpClient;374 const client = yield* HttpClient.HttpClient;
356 const requestUrl = new URL(normalizedDiscoveryUrl);375
357 for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {376 const attempt = (target: string) =>
358 requestUrl.searchParams.set(name, value);377 Effect.gen(function* () {
359 }378 const requestUrl = new URL(target);
360 let request = HttpClientRequest.get(requestUrl.toString()).pipe(379 for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {
361 HttpClientRequest.setHeader("Accept", "application/json, */*"),380 requestUrl.searchParams.set(name, value);
362 );381 }
363 for (const [name, value] of Object.entries(credentials?.headers ?? {})) {382 let request = HttpClientRequest.get(requestUrl.toString()).pipe(
364 request = HttpClientRequest.setHeader(request, name, value);383 HttpClientRequest.setHeader("Accept", "application/json, */*"),
365 }384 );
366 const response = yield* client.execute(request).pipe(385 for (const [name, value] of Object.entries(credentials?.headers ?? {})) {
367 Effect.mapError(386 request = HttpClientRequest.setHeader(request, name, value);
368 () =>387 }
369 new OpenApiParseError({388 const response = yield* client
370 message: "Failed to fetch Google Discovery document",389 .execute(request)
371 }),390 .pipe(
372 ),391 Effect.mapError(
373 );392 () => new OpenApiParseError({ message: "Failed to fetch Google Discovery document" }),
374 if (response.status < 200 || response.status >= 300) {393 ),
375 return yield* new OpenApiParseError({394 );
376 message: `Failed to fetch Google Discovery document: HTTP ${response.status}`,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 }407
379 return yield* response.text.pipe(408 // Normalization maps a service-hosted URL onto the central directory for a
380 Effect.mapError(409 // STABLE IDENTITY, but the directory does not list every service — Google
381 () =>410 // Ads answers only on googleads.googleapis.com, so the canonical form 404s
382 new OpenApiParseError({411 // for a URL the user pasted that works. Fall back to the host they named
383 message: "Failed to read Google Discovery document body",412 // rather than maintaining an allowlist of every such service forever.
384 }),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 },
packages/plugins/openapi/src/providers/google/presets.ts+1−0
@@ -346,6 +346,7 @@ export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets · 345 unmodified lines
346 ...(preset.featured ? { featured: preset.featured } : {}),346 ...(preset.featured ? { featured: preset.featured } : {}),
347 family: "google",347 family: "google",
348 specFormat: "google-discovery",348 specFormat: "google-discovery",
349 registryListed: true,
349 defaultSlug: googleServiceSlug(preset.id),350 defaultSlug: googleServiceSlug(preset.id),
350 authTemplate: googleCatalogAuthTemplate(preset.id),351 authTemplate: googleCatalogAuthTemplate(preset.id),
351 ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}),352 ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}),
packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts+28−0
@@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab · 3 unmodified lines
4import { createExecutor, IntegrationSlug } from "@executor-js/sdk";4import { createExecutor, IntegrationSlug } from "@executor-js/sdk";
5import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";5import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
6import { openApiPlugin, parse } from "@executor-js/plugin-openapi";6import { openApiPlugin, parse } from "@executor-js/plugin-openapi";
7import { resolveSpecFormatAdapter } from "../../sdk/spec-format";
7import type { AuthenticationInput } from "@executor-js/plugin-openapi";8import type { AuthenticationInput } from "@executor-js/plugin-openapi";
8 9
9import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";10import { 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
295it.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);
packages/plugins/openapi/src/providers/google/spec-format-adapter.ts+2−0
@@ -6,6 +6,7 @@ import { type DerivedIdentity, type SpecFormatAdapter } from "../../sdk/spec-for · 5 unmodified lines
6import {6import {
7 convertGoogleDiscoveryBundleToOpenApi,7 convertGoogleDiscoveryBundleToOpenApi,
8 fetchGoogleDiscoveryDocument,8 fetchGoogleDiscoveryDocument,
9 isGoogleDiscoveryUrl,
9 normalizeGoogleDiscoveryUrl,10 normalizeGoogleDiscoveryUrl,
10} from "./discovery";11} from "./discovery";
11 12
@@ -47,6 +48,7 @@ export const deriveGoogleDiscoveryIdentity = (doc: unknown): DerivedIdentity | n · 35 unmodified lines
47 48
48export const googleDiscoveryAdapter: SpecFormatAdapter = {49export const googleDiscoveryAdapter: SpecFormatAdapter = {
49 id: "google-discovery",50 id: "google-discovery",
51 detectsUrl: isGoogleDiscoveryUrl,
50 fetch: (input) =>52 fetch: (input) =>
51 Effect.gen(function* () {53 Effect.gen(function* () {
52 const documents = yield* Effect.forEach(54 const documents = yield* Effect.forEach(
packages/plugins/openapi/src/providers/microsoft/presets.ts+1−0
@@ -569,6 +569,7 @@ export const microsoftCatalog: readonly IntegrationPreset[] = microsoftGraphScop · 568 unmodified lines
569 ...(preset.featured ? { featured: preset.featured } : {}),569 ...(preset.featured ? { featured: preset.featured } : {}),
570 family: "microsoft",570 family: "microsoft",
571 specFormat: "microsoft-graph",571 specFormat: "microsoft-graph",
572 registryListed: true,
572 defaultSlug: microsoftServiceSlug(preset.id),573 defaultSlug: microsoftServiceSlug(preset.id),
573 authTemplate: microsoftGraphCatalogAuthTemplate(preset),574 authTemplate: microsoftGraphCatalogAuthTemplate(preset),
574 ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}),575 ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}),
packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts+19−0
@@ -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
225it("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});
packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts+21−0
@@ -3,6 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines
3import type { SpecFormatAdapter } from "../../sdk/spec-format";3import type { SpecFormatAdapter } from "../../sdk/spec-format";
4 4
5import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph";5import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph";
6import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets";
6import { microsoftGraphPresetIdsForSliceAsset, microsoftGraphSliceAssetFromUrl } from "./slices";7import { microsoftGraphPresetIdsForSliceAsset, microsoftGraphSliceAssetFromUrl } from "./slices";
7 8
8const fragmentPresetIds = (hash: string): readonly string[] =>9const 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`. */
49const 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
43export const microsoftGraphAdapter: SpecFormatAdapter = {63export const microsoftGraphAdapter: SpecFormatAdapter = {
44 id: "microsoft-graph",64 id: "microsoft-graph",
65 detectsUrl: detectsGraphUrl,
45 fetch: (input) =>66 fetch: (input) =>
46 buildMicrosoftGraphOpenApiSpec(67 buildMicrosoftGraphOpenApiSpec(
47 graphCatalogSelection(input.urls[0]),68 graphCatalogSelection(input.urls[0]),
packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx+44−5
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
25 type AuthMethodRow,25 type AuthMethodRow,
26 type AuthMethodSeed,26 type AuthMethodSeed,
27} from "@executor-js/react/components/auth-method-list-editor";27} from "@executor-js/react/components/auth-method-list-editor";
28import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
28import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";29import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";
29import { FieldLabel } from "@executor-js/react/components/field";30import { FieldLabel } from "@executor-js/react/components/field";
30import { FloatActions } from "@executor-js/react/components/float-actions";31import { FloatActions } from "@executor-js/react/components/float-actions";
@@ -170,18 +171,40 @@ export default function AddOpenApiIntegration(props: { · 139 unmodified lines
170 initialUrl?: string;171 initialUrl?: string;
171 initialPreset?: string;172 initialPreset?: string;
172 initialNamespace?: string;173 initialNamespace?: string;
174 initialAuthHeader?: string;
175 initialAuthNote?: string;
176 initialSpecOverrides?: string;
173}) {177}) {
174 const integrationPlugins = useIntegrationPlugins();178 const integrationPlugins = useIntegrationPlugins();
175 const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");179 const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");
176 const openApiPresets = openApiPlugin?.presets;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 const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null);191 const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null);
179 const [analyzing, setAnalyzing] = useState(false);192 const [analyzing, setAnalyzing] = useState(false);
180 const [analyzeError, setAnalyzeError] = useState<string | null>(null);193 const [analyzeError, setAnalyzeError] = useState<string | null>(null);
181 194
182 const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl);195 const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl);
183 const presetSpecOverrides = decodeOpenApiSpecOverrides(activePreset?.specOverrides);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 // After analysis209 // After analysis
187 const [preview, setPreview] = useState<SpecPreviewSummary | null>(null);210 const [preview, setPreview] = useState<SpecPreviewSummary | null>(null);
@@ -297,14 +320,26 @@ export default function AddOpenApiIntegration(props: { · 109 unmodified lines
297 ...(preview?.headerPresets ?? []).map((preset) => preset.label),320 ...(preview?.headerPresets ?? []).map((preset) => preset.label),
298 ...(preview?.oauth2Presets ?? []).map((preset) => preset.label),321 ...(preview?.oauth2Presets ?? []).map((preset) => preset.label),
299 ];322 ];
300 return authenticationTemplate.map(323 const detected = authenticationTemplate.map(
301 (template: Authentication, index: number): AuthMethodSeed => ({324 (template: Authentication, index: number): AuthMethodSeed => ({
302 value: editorValueFromAuthentication(template),325 value: editorValueFromAuthentication(template),
303 slug: String(template.slug),326 slug: String(template.slug),
304 ...(labels[index] !== undefined ? { label: labels[index] } : {}),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 const authMethodList = useAuthMethodList(authMethodSeeds);343 const authMethodList = useAuthMethodList(authMethodSeeds);
309 344
310 // The methods to register, mapped back to stored `Authentication[]`. Drops345 // The methods to register, mapped back to stored `Authentication[]`. Drops
@@ -597,10 +632,14 @@ export default function AddOpenApiIntegration(props: { · 286 unmodified lines
597 <AuthMethodListEditor632 <AuthMethodListEditor
598 list={authMethodList}633 list={authMethodList}
599 emptyHint="No authentication detected. Add a method, or add the integration without auth and connect an account from the integration page later."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 {preview ? (643 {preview ? (
605 <AddOpenApiHealthCheckSection644 <AddOpenApiHealthCheckSection
606 presetHealthCheck={activePreset?.healthCheck}645 presetHealthCheck={activePreset?.healthCheck}
packages/plugins/openapi/src/react/integration-plugin.test.ts+241−0
@@ -0,0 +1,241 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Option } from "effect";
3
4import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides";
5import { Exit } from "effect";
6
7import {
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
19const scopeOverride = decodeOpenApiSpecOverrides([
20 {
21 op: "replace",
22 path: "/components/securitySchemes/OAuth2/flows/authorizationCode/scopes",
23 value: { "files:read": "" },
24 },
25])!;
26
27describe("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
70describe("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
126describe("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
154describe("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});
packages/plugins/openapi/src/react/integration-plugin.ts+252−17
@@ -1,6 +1,234 @@
1import { lazy } from "react";1import { lazy, useCallback } from "react";
2import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client";2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import type {
5 IntegrationPlugin,
6 IntegrationPreset,
7 IntegrationQuickAddInput,
8 IntegrationQuickAddResult,
9} from "@executor-js/sdk/client";
10import { AuthTemplateSlug } from "@executor-js/sdk/shared";
11import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";
12import { placementFromHeaderPattern, type Placement } from "@executor-js/react/lib/auth-placements";
13import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
3import { openApiPresets } from "../sdk/presets";14import { openApiPresets } from "../sdk/presets";
15import { detectedAuthenticationTemplates } from "../sdk/derive-auth";
16import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
17import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config";
18import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides";
19
20const 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. */
33const 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. */
52export 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. */
76export 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. */
104export 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
133export 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. */
152export 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
212function 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
5const importAdd = () => import("./AddOpenApiIntegration");233const importAdd = () => import("./AddOpenApiIntegration");
6const importEditSheet = () => import("./UpdateSpecSection");234const importEditSheet = () => import("./UpdateSpecSection");
@@ -10,20 +238,27 @@ export interface OpenApiClientConfig { · 3 unmodified lines
10 readonly presets?: readonly IntegrationPreset[];238 readonly presets?: readonly IntegrationPreset[];
11}239}
12 240
13export const createOpenApiIntegrationPlugin = (241export const createOpenApiIntegrationPlugin = (config?: OpenApiClientConfig): IntegrationPlugin => {
14 config?: OpenApiClientConfig,242 // Built-ins are registry-listed (the picker shows the registry's card);
15): IntegrationPlugin => ({243 // a deployment's custom presets are not, and keep their own cards.
16 key: "openapi",244 const presets: readonly IntegrationPreset[] = [
17 label: "OpenAPI",245 ...openApiPresets.map((preset) => ({ ...preset, registryListed: true })),
18 add: lazy(importAdd),246 ...(config?.presets ?? []),
19 editSheet: lazy(importEditSheet),247 ];
20 accounts: lazy(importAccounts),248 return {
21 presets: [...openApiPresets, ...(config?.presets ?? [])],249 key: "openapi",
22 preload: () => {250 label: "OpenAPI",
23 void importAdd();251 add: lazy(importAdd),
24 void importEditSheet();252 editSheet: lazy(importEditSheet),
25 void importAccounts();253 accounts: lazy(importAccounts),
26 },254 presets,
27});255 preload: () => {
256 void importAdd();
257 void importEditSheet();
258 void importAccounts();
259 },
260 useQuickAdd: makeUseQuickAdd(presets),
261 };
262};
28 263
29export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin();264export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin();
packages/plugins/openapi/src/sdk/config.ts+5−0
@@ -52,6 +52,11 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 51 unmodified lines
52 specUrl: Schema.optional(Schema.String),52 specUrl: Schema.optional(Schema.String),
53 /** Optional base URL override. */53 /** Optional base URL override. */
54 baseUrl: Schema.optional(Schema.String),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 /** Static headers applied to every request (no secret material). */60 /** Static headers applied to every request (no secret material). */
56 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),61 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
57 /** Static query params applied to every request (no secret material). */62 /** Static query params applied to every request (no secret material). */
packages/plugins/openapi/src/sdk/derive-auth.test.ts+42−0
@@ -71,3 +71,45 @@ describe("detectedAuthenticationTemplates", () => { · 70 unmodified lines
71 ).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]);71 ).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]);
72 });72 });
73});73});
74
75describe("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});
packages/plugins/openapi/src/sdk/derive-auth.ts+24−11
@@ -104,20 +104,33 @@ const apiKeyTemplateFromHeaderPreset = ( · 103 unmodified lines
104 preset: HeaderPreset,104 preset: HeaderPreset,
105 slug: AuthTemplateSlug,105 slug: AuthTemplateSlug,
106): APIKeyAuthentication => {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 return {111 return {
109 slug,112 slug,
110 kind: "apikey",113 kind: "apikey",
111 placements: preset.secretHeaders.map((headerName) => {114 placements: [
112 const prefix = headerPrefix(preset, headerName);115 ...preset.secretHeaders.map((headerName) => {
113 const variable = variables.get(headerName);116 const prefix = headerPrefix(preset, headerName);
114 return {117 const variable = variables.get(headerName);
115 carrier: "header" as const,118 return {
116 name: headerName,119 carrier: "header" as const,
117 ...(prefix ? { prefix } : {}),120 name: headerName,
118 ...(variable ? { variable } : {}),121 ...(prefix ? { prefix } : {}),
119 };122 ...(variable ? { variable } : {}),
120 }),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
packages/plugins/openapi/src/sdk/plugin.ts+11−1
@@ -98,6 +98,10 @@ export interface OpenApiSpecConfig { · 97 unmodified lines
98 * then the title). */98 * then the title). */
99 readonly description?: string;99 readonly description?: string;
100 readonly baseUrl?: string;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 /** Static headers applied to every request (no secret material). */105 /** Static headers applied to every request (no secret material). */
102 readonly headers?: Record<string, string>;106 readonly headers?: Record<string, string>;
103 /** Static query params applied to every request. */107 /** Static query params applied to every request. */
@@ -307,6 +311,7 @@ const AddIntegrationInputSchema = Schema.Struct({ · 203 unmodified lines
307 name: Schema.optional(Schema.String),311 name: Schema.optional(Schema.String),
308 description: Schema.optional(Schema.String),312 description: Schema.optional(Schema.String),
309 baseUrl: Schema.optional(Schema.String),313 baseUrl: Schema.optional(Schema.String),
314 displayDomain: Schema.optional(Schema.String),
310 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),315 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
311 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),316 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
312 specFormat: Schema.optional(Schema.String),317 specFormat: Schema.optional(Schema.String),
@@ -603,7 +608,9 @@ export const describeOpenApiIntegrationDisplay = ( · 290 unmodified lines
603): { readonly url?: string; readonly family?: string } => {608): { readonly url?: string; readonly family?: string } => {
604 const config = decodeOpenApiIntegrationConfig(record.config);609 const config = decodeOpenApiIntegrationConfig(record.config);
605 return {610 return {
606 url: config?.baseUrl ?? config?.specUrl,611 url:
612 config?.baseUrl ??
613 (config?.displayDomain ? `https://${config.displayDomain}` : config?.specUrl),
607 ...(config?.family ? { family: config.family } : {}),614 ...(config?.family ? { family: config.family } : {}),
608 };615 };
609};616};
@@ -661,6 +668,7 @@ export const openApiPlugin = definePlugin< · 51 unmodified lines
661 const adapter = yield* resolveSpecFormatAdapter(668 const adapter = yield* resolveSpecFormatAdapter(
662 options?.specFormats ?? [],669 options?.specFormats ?? [],
663 config.specFormat,670 config.specFormat,
671 config.spec.kind === "url" ? config.spec.url : undefined,
664 );672 );
665 if (adapter) {673 if (adapter) {
666 if (config.spec.kind !== "url") {674 if (config.spec.kind !== "url") {
@@ -782,6 +790,7 @@ export const openApiPlugin = definePlugin< · 115 unmodified lines
782 const adapter = yield* resolveSpecFormatAdapter(790 const adapter = yield* resolveSpecFormatAdapter(
783 options?.specFormats ?? [],791 options?.specFormats ?? [],
784 config.specFormat,792 config.specFormat,
793 config.spec.kind === "url" ? config.spec.url : undefined,
785 );794 );
786 const derivedIdentity =795 const derivedIdentity =
787 adapter?.deriveIdentity && resolved.document796 adapter?.deriveIdentity && resolved.document
@@ -865,6 +874,7 @@ export const openApiPlugin = definePlugin< · 77 unmodified lines
865 // resolved per call from the operation's `servers` (extracted from874 // resolved per call from the operation's `servers` (extracted from
866 // the spec), so we never bake a derived base URL into the config.875 // the spec), so we never bake a derived base URL into the config.
867 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),876 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
877 ...(config.displayDomain ? { displayDomain: config.displayDomain } : {}),
868 ...(config.headers ? { headers: config.headers } : {}),878 ...(config.headers ? { headers: config.headers } : {}),
869 ...(config.queryParams ? { queryParams: config.queryParams } : {}),879 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
870 ...(config.specFormat ? { specFormat: config.specFormat } : {}),880 ...(config.specFormat ? { specFormat: config.specFormat } : {}),
packages/plugins/openapi/src/sdk/presets.ts+3−8
@@ -145,7 +145,9 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 144 unmodified lines
145 id: "axiom",145 id: "axiom",
146 name: "Axiom",146 name: "Axiom",
147 summary: "Log ingestion, querying, datasets, and monitors.",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 icon: "https://integrations.sh/logo/axiom.co",151 icon: "https://integrations.sh/logo/axiom.co",
150 },152 },
151 {153 {
@@ -169,13 +171,6 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 17 unmodified lines
169 url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml",171 url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml",
170 icon: "https://integrations.sh/logo/digitalocean.com",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 id: "val-town",175 id: "val-town",
181 name: "Val Town",176 name: "Val Town",
packages/plugins/openapi/src/sdk/preview.ts+17−10
@@ -122,6 +122,9 @@ export const HeaderPreset = Schema.Struct({ · 121 unmodified lines
122 headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)),122 headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)),
123 /** Which headers should be stored as secrets */123 /** Which headers should be stored as secrets */
124 secretHeaders: Schema.Array(Schema.String),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});
126export type HeaderPreset = typeof HeaderPreset.Type;129export type HeaderPreset = typeof HeaderPreset.Type;
127 130
@@ -347,6 +350,7 @@ const buildHeaderPresets = ( · 219 unmodified lines
347 350
348 const headers: Record<string, string | null> = {};351 const headers: Record<string, string | null> = {};
349 const secretHeaders: string[] = [];352 const secretHeaders: string[] = [];
353 const secretQueryParams: string[] = [];
350 const labelParts: string[] = [];354 const labelParts: string[] = [];
351 355
352 for (const scheme of resolved) {356 for (const scheme of resolved) {
@@ -363,8 +367,16 @@ const buildHeaderPresets = ( · 10 unmodified lines
363 headers[headerName] = null;367 headers[headerName] = null;
364 secretHeaders.push(headerName);368 secretHeaders.push(headerName);
365 labelParts.push(scheme.name);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 } else if (scheme.type === "apiKey") {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 } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {380 } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
369 return [];381 return [];
370 } else {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) {387 // A strategy in which nothing is renderable (cookie-only, or an exotic
376 return [388 // scheme type) yields no preset rather than an empty one.
377 HeaderPreset.make({389 if (secretHeaders.length === 0 && secretQueryParams.length === 0) return [];
378 label: labelParts.join(" + "),
379 headers: {},
380 secretHeaders: [],
381 }),
382 ];
383 }
384 390
385 return [391 return [
386 HeaderPreset.make({392 HeaderPreset.make({
387 label: labelParts.join(" + "),393 label: labelParts.join(" + "),
388 headers,394 headers,
389 secretHeaders,395 secretHeaders,
396 ...(secretQueryParams.length > 0 ? { secretQueryParams } : {}),
390 }),397 }),
391 ];398 ];
392 });399 });
packages/plugins/openapi/src/sdk/spec-format.ts+12−1
@@ -41,13 +41,24 @@ export interface SpecFormatAdapter { · 40 unmodified lines
41 readonly id: string;41 readonly id: string;
42 readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>;42 readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>;
43 readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null;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
46export const resolveSpecFormatAdapter = (51export const resolveSpecFormatAdapter = (
47 adapters: readonly SpecFormatAdapter[],52 adapters: readonly SpecFormatAdapter[],
48 id: string | undefined,53 id: string | undefined,
54 url?: string,
49): Effect.Effect<SpecFormatAdapter | null, OpenApiParseError> => {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 const adapter = adapters.find((candidate) => candidate.id === id);62 const adapter = adapters.find((candidate) => candidate.id === id);
52 if (adapter) return Effect.succeed(adapter);63 if (adapter) return Effect.succeed(adapter);
53 return Effect.fail(new OpenApiParseError({ message: `Unknown OpenAPI spec format: ${id}` }));64 return Effect.fail(new OpenApiParseError({ message: `Unknown OpenAPI spec format: ${id}` }));
packages/react/src/api/analytics.test.ts+1−1
@@ -13,7 +13,7 @@ afterEach(() => { · 12 unmodified lines
13 13
14describe("analytics seam", () => {14describe("analytics seam", () => {
15 it("is a no-op when no client is mounted", () => {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 it("forwards name and properties to the mounted client", () => {19 it("forwards name and properties to the mounted client", () => {
packages/react/src/api/analytics.tsx+11−1
@@ -31,12 +31,22 @@ type Owner = "org" | "user"; · 30 unmodified lines
31 31
32export interface AnalyticsEvents {32export interface AnalyticsEvents {
33 // ── Integrations ─────────────────────────────────────────────────────────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 integration_detect_submitted: {38 integration_detect_submitted: {
36 success: boolean;39 success: boolean;
37 detected_kind?: string;40 detected_kind?: string;
38 confidence?: string;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 integration_add_started: {50 integration_add_started: {
41 plugin_key: string;51 plugin_key: string;
42 via: "detect" | "manual" | "preset" | "command_palette" | "catalog";52 via: "detect" | "manual" | "preset" | "command_palette" | "catalog";
packages/react/src/components/add-account-modal.tsx+27−2
@@ -27,6 +27,7 @@ import { · 26 unmodified lines
27 checkConnectionHealth,27 checkConnectionHealth,
28 connectionsAllAtom,28 connectionsAllAtom,
29 createOAuthClientOptimistic,29 createOAuthClientOptimistic,
30 integrationAtom,
30 integrationHealthCheckAtom,31 integrationHealthCheckAtom,
31 integrationHealthCheckCandidatesAtom,32 integrationHealthCheckCandidatesAtom,
32 oauthClientsOptimisticAtom,33 oauthClientsOptimisticAtom,
@@ -85,6 +86,7 @@ import { · 52 unmodified lines
85} from "./oauth-client-form";86} from "./oauth-client-form";
86import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog";87import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog";
87import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";88import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";
89import { CredentialGuidancePanel } from "./credential-guidance";
88import { PlacementLine, type AuthMethod } from "../lib/auth-placements";90import { PlacementLine, type AuthMethod } from "../lib/auth-placements";
89import { connectionIdentifier } from "../lib/connection-name";91import { connectionIdentifier } from "../lib/connection-name";
90import { Badge } from "./badge";92import { Badge } from "./badge";
@@ -625,6 +627,14 @@ export const connectionExistsMessage = (label: string): string => · 534 unmodified lines
625 * explicit choice. Personal: a connection is most often a personal credential. */627 * explicit choice. Personal: a connection is most often a personal credential. */
626export const DEFAULT_CONNECTION_OWNER: Owner = "user";628export 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. */
635export const preferredMethodId = (methods: readonly AuthMethod[]): string =>
636 (methods.find((method) => method.kind !== "oauth") ?? methods[0])?.id ?? "";
637
628const authMethodKey = (method: AuthMethod): string =>638const authMethodKey = (method: AuthMethod): string =>
629 method.source === "custom" ? `custom:${String(method.template)}` : `declared:${method.id}`;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 const [addingMethod, setAddingMethod] = useState(false);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 // One value per distinct credential input (`variable → pasted value`). A1437 // One value per distinct credential input (`variable → pasted value`). A
1428 // single-secret method has just `{ token }`; a method with two distinct inputs1438 // single-secret method has just `{ token }`; a method with two distinct inputs
1429 // (e.g. Datadog's two keys) collects one value per variable.1439 // (e.g. Datadog's two keys) collects one value per variable.
@@ -1501,6 +1511,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 71 unmodified lines
1501 // The integration's declared health check + its candidate operations. When a1511 // The integration's declared health check + its candidate operations. When a
1502 // check is configured we probe against it; when not, the user picks one of the1512 // check is configured we probe against it; when not, the user picks one of the
1503 // candidates inline to test the key (and we save it).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 const healthCheckResult = useAtomValue(integrationHealthCheckAtom(integration));1520 const healthCheckResult = useAtomValue(integrationHealthCheckAtom(integration));
1505 const configuredHealthCheck = AsyncResult.isSuccess(healthCheckResult)1521 const configuredHealthCheck = AsyncResult.isSuccess(healthCheckResult)
1506 ? healthCheckResult.value1522 ? healthCheckResult.value
@@ -1605,7 +1621,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 98 unmodified lines
1605 m.id === initialState.template || String(m.template) === initialState.template,1621 m.id === initialState.template || String(m.template) === initialState.template,
1606 )1622 )
1607 : undefined;1623 : undefined;
1608 setMethodId(initialMethod?.id ?? allMethods[0]!.id);1624 setMethodId(initialMethod?.id ?? preferredMethodId(allMethods));
1609 }, [allMethods, initialState?.template, methodId]);1625 }, [allMethods, initialState?.template, methodId]);
1610 1626
1611 // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link.1627 // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link.
@@ -2894,6 +2910,15 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1282 unmodified lines
2894 <div className="space-y-2">2910 <div className="space-y-2">
2895 <StepHeader index={1} label={authStepLabel} />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 {isOAuth && method ? (2922 {isOAuth && method ? (
2898 cimdActive ? (2923 cimdActive ? (
2899 <div className="space-y-2 rounded-lg border border-ring/40 bg-accent/30 px-3 py-3">2924 <div className="space-y-2 rounded-lg border border-ring/40 bg-accent/30 px-3 py-3">
packages/react/src/components/auth-method-list-editor.tsx+33−15
@@ -142,10 +142,11 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) { · 141 unmodified lines
142 <div className="flex items-center justify-between">142 <div className="flex items-center justify-between">
143 <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">143 <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
144 {detected ? <LockIcon className="size-3 shrink-0" aria-hidden /> : null}144 {detected ? <LockIcon className="size-3 shrink-0" aria-hidden /> : null}
145 <span>145 {/* Named by what it is. "Method 1" told the reader nothing
146 Method {index + 1}146 except that there might be a Method 2, and next to a
147 {row.seedLabel ? ` · ${row.seedLabel}` : ""}147 masked value it read as a locked credential field —
148 </span>148 people tried to paste their key into it. */}
149 <span>{detected ? detectedMethodTitle(row) : `Method ${index + 1}`}</span>
149 </span>150 </span>
150 <Button151 <Button
151 type="button"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. */
187function 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/** One read-only `label value` line, mono value, for the detected summary. */193/** One read-only `label value` line, mono value, for the detected summary. */
185function SpecField(props: { readonly label: string; readonly value: string }) {194function SpecField(props: { readonly label: string; readonly value: string }) {
186 return (195 return (
@@ -204,16 +213,11 @@ function DetectedMethodSummary(props: { · 17 unmodified lines
204 const { value, oauthMetadata } = props;213 const { value, oauthMetadata } = props;
205 // Name the auth kind explicitly: a detection label like MCP's "Detected"214 // Name the auth kind explicitly: a detection label like MCP's "Detected"
206 // doesn't say whether it's OAuth or an API key, so surface it here.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 return (216 return (
210 <div className="space-y-2">217 <div className="space-y-2">
211 <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">
212 {kindLabel}
213 </p>
214 <div218 <div
215 aria-disabled219 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 {value.kind === "none" && (222 {value.kind === "none" && (
219 <p className="text-xs">No credential — tools are callable without an account.</p>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 {value.kind === "apikey" &&226 {value.kind === "apikey" &&
223 (value.placements.length > 0 ? (227 (value.placements.length > 0 ? (
224 <div className="flex flex-wrap gap-x-4 gap-y-1">228 <div className="space-y-1">
225 {value.placements.map((placement, i: number) => (229 <p className="text-xs">This API takes a key, sent as:</p>
226 <PlacementLine key={i} placement={placement} />230 <div className="flex flex-wrap gap-x-4 gap-y-1">
227 ))}231 {value.placements.map((placement, i: number) => (
232 <PlacementLine key={i} placement={placement} />
233 ))}
234 </div>
228 </div>235 </div>
229 ) : null)}236 ) : null)}
230 237
@@ -246,7 +253,18 @@ function DetectedMethodSummary(props: { · 15 unmodified lines
246 ))}253 ))}
247 </div>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 </div>268 </div>
251 );269 );
252}270}
packages/react/src/components/credential-guidance.tsx+168−0
@@ -0,0 +1,168 @@
1import { useMemo, useState } from "react";
2import { ArrowUpRightIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
3import { getDomain } from "tldts";
4
5import { trackEvent } from "../api/analytics";
6import { useCredentialGuidance } from "../lib/integrations-sh-catalog";
7import { 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. */
25function 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
69export 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. */
133function 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}
packages/react/src/components/integration-favicon.tsx+0−1
@@ -209,7 +209,6 @@ export function IntegrationFavicon({ · 208 unmodified lines
209 alt=""209 alt=""
210 width={size}210 width={size}
211 height={size}211 height={size}
212 loading="lazy"
213 onError={() =>212 onError={() =>
214 setFailedSrcs((current) =>213 setFailedSrcs((current) =>
215 current.includes(failedCandidate) ? current : [...current, failedCandidate],214 current.includes(failedCandidate) ? current : [...current, failedCandidate],
packages/react/src/console-routes.ts+2−0
@@ -37,6 +37,7 @@ export const CONSOLE_ROUTE_PATHS = [ · 36 unmodified lines
37 "/connect/$integrationSlug",37 "/connect/$integrationSlug",
38 "/integrations/$namespace",38 "/integrations/$namespace",
39 "/integrations/add/$pluginKey",39 "/integrations/add/$pluginKey",
40 "/integrations/browse",
40 "/policies",41 "/policies",
41 "/secrets",42 "/secrets",
42 "/tools",43 "/tools",
@@ -78,6 +79,7 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 35 unmodified lines
78 "/integrations/add/$pluginKey",79 "/integrations/add/$pluginKey",
79 route("/integrations/add/$pluginKey", file("integrations.add.$pluginKey.tsx")),80 route("/integrations/add/$pluginKey", file("integrations.add.$pluginKey.tsx")),
80 ],81 ],
82 ["/integrations/browse", route("/integrations/browse", file("integrations.browse.tsx"))],
81 ["/policies", route("/policies", file("policies.tsx"))],83 ["/policies", route("/policies", file("policies.tsx"))],
82 ["/secrets", route("/secrets", file("secrets.tsx"))],84 ["/secrets", route("/secrets", file("secrets.tsx"))],
83 ["/tools", route("/tools", file("tools.tsx"))],85 ["/tools", route("/tools", file("tools.tsx"))],
packages/react/src/lib/auth-placements.test.ts+23−1
@@ -1,7 +1,7 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import type { AuthMethodDescriptor } from "@executor-js/sdk/shared";2import type { AuthMethodDescriptor } from "@executor-js/sdk/shared";
3 3
4import { authMethodsFromDescriptors } from "./auth-placements";4import { authMethodsFromDescriptors, placementFromHeaderPattern } from "./auth-placements";
5 5
6describe("authMethodsFromDescriptors", () => {6describe("authMethodsFromDescriptors", () => {
7 it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => {7 it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => {
@@ -101,3 +101,25 @@ describe("authMethodsFromDescriptors", () => { · 93 unmodified lines
101 expect(authMethodsFromDescriptors([])).toEqual([]);101 expect(authMethodsFromDescriptors([])).toEqual([]);
102 });102 });
103});103});
104
105describe("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});
packages/react/src/lib/auth-placements.tsx+16−0
@@ -40,6 +40,22 @@ export interface Placement { · 39 unmodified lines
40/** A fresh, empty header placement — the default first row in an editor. */40/** A fresh, empty header placement — the default first row in an editor. */
41export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", prefix: "" });41export 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. */
47export 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/** What an auth method is, presentationally. `kind` drives the credential UI:59/** What an auth method is, presentationally. `kind` drives the credential UI:
44 * `oauth` shows a Connect button, `none` creates a connection with no60 * `oauth` shows a Connect button, `none` creates a connection with no
45 * credential inputs, and apikey/custom fill secrets across `placements`.61 * credential inputs, and apikey/custom fill secrets across `placements`.
packages/react/src/lib/integrations-sh-catalog.ts+440−43
@@ -9,7 +9,7 @@ · 8 unmodified lines
9// GraphQL endpoint).9// GraphQL endpoint).
10// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
11 11
12import { useEffect, useRef, useState } from "react";12import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13import * as Data from "effect/Data";13import * as Data from "effect/Data";
14import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
15import * as Exit from "effect/Exit";15import * as Exit from "effect/Exit";
@@ -28,10 +28,49 @@ export type CatalogKind = (typeof CONNECTABLE_KINDS)[number]; · 12 unmodified lines
28const isConnectableKind = (kind: string): kind is CatalogKind =>28const isConnectableKind = (kind: string): kind is CatalogKind =>
29 (CONNECTABLE_KINDS as readonly string[]).includes(kind);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. */
39export 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
31export interface CatalogSearchEntry {64export interface CatalogSearchEntry {
32 readonly domain: string;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 readonly description: string;69 readonly description: string;
34 readonly kinds: readonly CatalogKind[];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
37export const catalogLogoUrl = (domain: string, size: number): string =>76export const catalogLogoUrl = (domain: string, size: number): string =>
@@ -44,7 +83,14 @@ class CatalogRequestError extends Data.TaggedError("CatalogRequestError")<{ · 6 unmodified lines
44const fetchCatalogJson = (url: URL): Effect.Effect<unknown, CatalogRequestError> =>83const fetchCatalogJson = (url: URL): Effect.Effect<unknown, CatalogRequestError> =>
45 Effect.gen(function* () {84 Effect.gen(function* () {
46 const response = yield* Effect.tryPromise({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 catch: () => new CatalogRequestError({ message: `Failed to reach ${url.host}.` }),94 catch: () => new CatalogRequestError({ message: `Failed to reach ${url.host}.` }),
49 });95 });
50 if (!response.ok) {96 if (!response.ok) {
@@ -66,34 +112,103 @@ const SearchResponse = Schema.Struct({ · 15 unmodified lines
66 results: Schema.Array(112 results: Schema.Array(
67 Schema.Struct({113 Schema.Struct({
68 domain: Schema.String,114 domain: Schema.String,
115 name: Schema.optional(Schema.String),
69 description: Schema.String,116 description: Schema.String,
70 kinds: Schema.Array(Schema.String),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});
74const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse);139const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse);
75 140
141export 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
150export 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
76export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntry[] =>158export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntry[] =>
77 Option.match(decodeSearchResponse(payload), {159 Option.match(decodeSearchResponse(payload), {
78 onNone: () => [],160 onNone: () => [],
79 onSome: ({ results }) =>161 onSome: ({ results }) =>
80 results162 results
81 .map((entry) => ({163 .map((entry) => {
82 domain: entry.domain,164 const surfaces = (entry.surfaces ?? []).flatMap((surface): readonly CatalogSurface[] =>
83 description: entry.description,165 isConnectableKind(surface.kind)
84 kinds: entry.kinds.filter(isConnectableKind),166 ? [
85 }))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 .filter((entry) => entry.kinds.length > 0),188 .filter((entry) => entry.kinds.length > 0),
87 });189 });
88 190
191export 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
89export const searchCatalog = (203export const searchCatalog = (
90 query: string,204 input: CatalogQuery,
91 limit = 10,205): Effect.Effect<CatalogSearchPage, CatalogRequestError> => {
92): Effect.Effect<readonly CatalogSearchEntry[], CatalogRequestError> => {
93 const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN);206 const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN);
94 url.searchParams.set("q", query);207 url.searchParams.set("q", input.query);
95 url.searchParams.set("limit", String(limit));208 url.searchParams.set("limit", String(input.limit ?? 10));
96 return Effect.map(fetchCatalogJson(url), parseCatalogSearch);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 ? surface.url250 ? surface.url
136 : undefined;251 : undefined;
137 252
253const normalizedProductToken = (value: string): string =>
254 value.toLowerCase().replace(/[^a-z0-9]/g, "");
255
138export const pickConnectTarget = (256export const pickConnectTarget = (
139 payload: unknown,257 payload: unknown,
140 kind: CatalogKind,258 kind: CatalogKind,
259 preferredName?: string,
141): CatalogConnectTarget | undefined =>260): CatalogConnectTarget | undefined =>
142 Option.match(decodeSurfaceDocument(payload), {261 Option.match(decodeSurfaceDocument(payload), {
143 onNone: () => undefined,262 onNone: () => undefined,
144 onSome: ({ surfaces }) => {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 const url = connectUrlOf(surface, kind);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
155export const resolveConnectTarget = (287export const resolveConnectTarget = (
156 domain: string,288 domain: string,
157 kinds: readonly CatalogKind[],289 kinds: readonly CatalogKind[],
290 preferredName?: string,
158): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => {291): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => {
159 const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN);292 const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN);
160 return Effect.map(fetchCatalogJson(url), (payload) => {293 return Effect.map(fetchCatalogJson(url), (payload) => {
161 for (const kind of kinds) {294 for (const kind of kinds) {
162 const target = pickConnectTarget(payload, kind);295 const target = pickConnectTarget(payload, kind, preferredName);
163 if (target) return target;296 if (target) return target;
164 }297 }
165 return undefined;298 return undefined;
@@ -172,16 +305,27 @@ export const resolveConnectTarget = ( · 6 unmodified lines
172 305
173/** Domains already represented by a loaded plugin's presets, so the catalog306/** Domains already represented by a loaded plugin's presets, so the catalog
174 * section doesn't repeat what the preset list above it already shows. */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. */
310export 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
175export const presetDomains = (plugins: readonly IntegrationPlugin[]): ReadonlySet<string> => {323export const presetDomains = (plugins: readonly IntegrationPlugin[]): ReadonlySet<string> => {
176 const domains = new Set<string>();324 const domains = new Set<string>();
177 for (const plugin of plugins) {325 for (const plugin of plugins) {
178 for (const preset of plugin.presets ?? []) {326 for (const preset of plugin.presets ?? []) {
179 for (const candidate of [preset.icon, preset.url]) {327 const domain = presetDomain(preset);
180 if (!candidate) continue;328 if (domain) domains.add(domain);
181 const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate);
182 const domain = logoMatch?.[1] ?? getDomain(candidate);
183 if (domain) domains.add(domain);
184 }
185 }329 }
186 }330 }
187 return domains;331 return domains;
@@ -206,6 +350,9 @@ export const filterCatalogEntries = ( · 18 unmodified lines
206 .map((entry) => ({350 .map((entry) => ({
207 ...entry,351 ...entry,
208 kinds: entry.kinds.filter((kind) => opts.availableKinds.includes(kind)),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 .filter((entry) => entry.kinds.length > 0);357 .filter((entry) => entry.kinds.length > 0);
211 358
@@ -213,43 +360,293 @@ export const filterCatalogEntries = ( · 1 unmodified line
213// Hook — debounced search with an in-session response cache360// Hook — debounced search with an in-session response cache
214// ---------------------------------------------------------------------------361// ---------------------------------------------------------------------------
215 362
216const SEARCH_DEBOUNCE_MS = 250;363const 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. */
217const MIN_QUERY_LENGTH = 2;366const MIN_QUERY_LENGTH = 2;
218const searchCache = new Map<string, readonly CatalogSearchEntry[]>();367const BROWSE_LIMIT = 60;
219 368
220export interface CatalogSearchState {369export interface CatalogSearchState {
221 readonly entries: readonly CatalogSearchEntry[];370 readonly entries: readonly CatalogSearchEntry[];
222 readonly loading: boolean;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
225export function useCatalogSearch(rawQuery: string): CatalogSearchState {387const cacheKey = (input: CatalogQuery): string =>
226 const query = rawQuery.trim().toLowerCase();388 `${input.kind ?? "all"}:${input.limit ?? 0}:${input.query}`;
227 const [state, setState] = useState<CatalogSearchState>({ entries: [], loading: false });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. */
394const SEARCH_TTL_MS = 60_000;
395const 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 */
408export 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 const generation = useRef(0);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 useEffect(() => {444 useEffect(() => {
231 const requestId = ++generation.current;445 const requestId = ++generation.current;
232 if (query.length < MIN_QUERY_LENGTH) {446 // A partial word is not yet a query; leave the previous results in place
233 setState({ entries: [], loading: false });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 return;451 return;
235 }452 }
236 const cached = searchCache.get(query);453 const request: CatalogQuery = { query, limit, ...(kind ? { kind } : {}) };
237 if (cached) {454 const cached = searchCache.get(cacheKey(request));
238 setState({ entries: cached, loading: false });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 return;462 return;
240 }463 }
241 setState((previous) => ({ ...previous, loading: true }));464 setState((previous) => ({ ...previous, loading: true }));
242 const timer = setTimeout(() => {465 const timer = setTimeout(
243 void Effect.runPromiseExit(searchCatalog(query)).then((exit) => {466 () => {
244 if (Exit.isSuccess(exit)) searchCache.set(query, exit.value);467 void Effect.runPromiseExit(searchCatalog(request)).then((exit) => {
245 if (generation.current !== requestId) return;468 if (Exit.isSuccess(exit)) {
246 // Reachability is best-effort — the presets and URL detection above469 searchCache.set(cacheKey(request), { at: Date.now(), entries: exit.value.entries });
247 // the catalog section keep working without it.470 }
248 setState({ entries: Exit.isSuccess(exit) ? exit.value : [], loading: false });471 if (generation.current !== requestId) return;
249 });472 setState({
250 }, SEARCH_DEBOUNCE_MS);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 return () => clearTimeout(timer);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
578export 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
589const 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});
602const decodeCredentials = Schema.decodeUnknownOption(CredentialsDocument);
603
604export 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
623const 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. */
627export 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 return state;651 return state;
255}652}
packages/react/src/multiplayer/shell.tsx+10−10
@@ -1,4 +1,4 @@
1import { Link, Outlet, useLocation, useParams } from "@tanstack/react-router";1import { Link, Outlet, useLocation, useNavigate, useParams } from "@tanstack/react-router";
2import { useEffect, useRef, useState, type ReactNode } from "react";2import { useEffect, useRef, useState, type ReactNode } from "react";
3import { useAtomValue } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -23,7 +23,6 @@ import { · 18 unmodified lines
23} from "../components/integration-favicon";23} from "../components/integration-favicon";
24import { CommandPalette } from "../components/command-palette";24import { CommandPalette } from "../components/command-palette";
25import { Wordmark } from "../components/wordmark";25import { Wordmark } from "../components/wordmark";
26import { ConnectDialog } from "../pages/integrations";
27import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";26import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
28import { useAuth } from "./auth-context";27import { useAuth } from "./auth-context";
29 28
@@ -428,7 +427,13 @@ export function Shell(props: ShellProps) { · 398 unmodified lines
428 const lastPathname = useRef(pathname);427 const lastPathname = useRef(pathname);
429 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);428 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
430 const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);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 if (lastPathname.current !== pathname) {437 if (lastPathname.current !== pathname) {
433 lastPathname.current = pathname;438 lastPathname.current = pathname;
434 if (mobileSidebarOpen) setMobileSidebarOpen(false);439 if (mobileSidebarOpen) setMobileSidebarOpen(false);
@@ -446,17 +451,13 @@ export function Shell(props: ShellProps) { · 11 unmodified lines
446 return (451 return (
447 <div className="flex h-screen overflow-hidden">452 <div className="flex h-screen overflow-hidden">
448 <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />453 <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
449 <ConnectDialog open={connectIntegrationOpen} onOpenChange={setConnectIntegrationOpen} />
450 {/* Desktop sidebar */}454 {/* Desktop sidebar */}
451 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">455 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
452 <SidebarContent456 <SidebarContent
453 {...props}457 {...props}
454 pathname={pathname}458 pathname={pathname}
455 onOpenCommands={() => setCommandPaletteOpen(true)}459 onOpenCommands={() => setCommandPaletteOpen(true)}
456 onOpenIntegrationConnect={() => {460 onOpenIntegrationConnect={openIntegrationBrowse}
457 setConnectIntegrationOpen(true);
458 trackEvent("integration_connect_dialog_opened");
459 }}
460 />461 />
461 </aside>462 </aside>
462 463
@@ -502,8 +503,7 @@ export function Shell(props: ShellProps) { · 39 unmodified lines
502 }}503 }}
503 onOpenIntegrationConnect={() => {504 onOpenIntegrationConnect={() => {
504 setMobileSidebarOpen(false);505 setMobileSidebarOpen(false);
505 setConnectIntegrationOpen(true);506 openIntegrationBrowse();
506 trackEvent("integration_connect_dialog_opened");
507 }}507 }}
508 />508 />
509 </div>509 </div>
packages/react/src/pages/integration-add.tsx+10−1
@@ -15,9 +15,14 @@ export function AddIntegrationPage(props: { · 14 unmodified lines
15 url?: string;15 url?: string;
16 preset?: string;16 preset?: string;
17 namespace?: string;17 namespace?: string;
18 authHeader?: string;
19 authNote?: string;
20 authKind?: string;
21 specOverrides?: string;
18}) {22}) {
19 useExecutorDocumentTitle("Add integration");23 useExecutorDocumentTitle("Add integration");
20 const { pluginKey, url, preset, namespace } = props;24 const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } =
25 props;
21 const navigate = useNavigate();26 const navigate = useNavigate();
22 const integrationPlugins = useIntegrationPlugins();27 const integrationPlugins = useIntegrationPlugins();
23 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);28 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
@@ -57,6 +62,10 @@ export function AddIntegrationPage(props: { · 33 unmodified lines
57 initialUrl={url}62 initialUrl={url}
58 initialPreset={preset}63 initialPreset={preset}
59 initialNamespace={namespace}64 initialNamespace={namespace}
65 initialAuthHeader={authHeader}
66 initialAuthNote={authNote}
67 initialAuthKind={authKind}
68 initialSpecOverrides={specOverrides}
60 onComplete={(slug?: string) => {69 onComplete={(slug?: string) => {
61 trackEvent("integration_added", {70 trackEvent("integration_added", {
62 plugin_key: pluginKey,71 plugin_key: pluginKey,
packages/react/src/pages/integration-browse.tsx+1041−0
@@ -0,0 +1,1041 @@
1import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";
3import { useAtomSet, useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import { PlusIcon, SearchIcon } from "lucide-react";
8import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared";
9import {
10 useIntegrationPlugins,
11 type IntegrationPlugin,
12 type IntegrationPreset,
13 type IntegrationQuickAddInput,
14 type IntegrationQuickAddResult,
15} from "@executor-js/sdk/client";
16
17import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms";
18import { slugifyNamespace } from "../plugins/namespace";
19import { trackEvent } from "../api/analytics";
20import { Button } from "../components/button";
21import { Input } from "../components/input";
22import { PageHeader } from "../components/page";
23import {
24 integrationFaviconUrl,
25 integrationInferredUrl,
26 integrationPresetIconUrl,
27} from "../components/integration-favicon";
28import { Skeleton } from "../components/skeleton";
29import { useExecutorDocumentTitle } from "../lib/document-title";
30import {
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
68const KIND_TO_PLUGIN_KEY: Record<string, string> = {
69 openapi: "openapi",
70 mcp: "mcp",
71 graphql: "graphql",
72 googleDiscovery: "google",
73};
74
75const 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"). */
84const 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. */
94const 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. */
106const 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
112const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = {
113 high: 3,
114 medium: 2,
115 low: 1,
116};
117
118const 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. */
125const 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. */
138const 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. */
147const 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
155type PresetEntry = {
156 readonly preset: IntegrationPreset;
157 readonly pluginKey: string;
158 readonly pluginLabel: string;
159};
160
161interface 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. */
195function 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. */
219function 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
247function 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
306function 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
327type 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. */
333function 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. */
352function 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
365export 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&apos;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}
packages/react/src/pages/integrations.tsx+24−444
@@ -1,30 +1,15 @@
1import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react";1import { Suspense, useMemo, type ReactNode } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link } from "@tanstack/react-router";
3import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";3import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import { PlusIcon } from "lucide-react";5import { PlusIcon } from "lucide-react";
8import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared";6import type { Integration } from "@executor-js/sdk/shared";
9import {7import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/client";
10 useIntegrationPlugins,8import { integrationsOptimisticAtom } from "../api/atoms";
11 type IntegrationPlugin,
12 type IntegrationPreset,
13} from "@executor-js/sdk/client";
14import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms";
15import { trackEvent } from "../api/analytics";9import { trackEvent } from "../api/analytics";
16import { McpInstallCard } from "../components/mcp-install-card";10import { McpInstallCard } from "../components/mcp-install-card";
17import { Button } from "../components/button";11import { Button } from "../components/button";
18import { PageContainer, PageHeader } from "../components/page";12import { PageContainer, PageHeader } from "../components/page";
19import { Badge } from "../components/badge";
20import { Input } from "../components/input";
21import {
22 Dialog,
23 DialogContent,
24 DialogDescription,
25 DialogHeader,
26 DialogTitle,
27} from "../components/dialog";
28import {13import {
29 CardStack,14 CardStack,
30 CardStackContent,15 CardStackContent,
@@ -32,7 +17,6 @@ import { · 1 unmodified line
32 CardStackEntryActions,17 CardStackEntryActions,
33 CardStackEntryContent,18 CardStackEntryContent,
34 CardStackEntryDescription,19 CardStackEntryDescription,
35 CardStackEntryMedia,
36 CardStackEntryTitle,20 CardStackEntryTitle,
37 CardStackHeader,21 CardStackHeader,
38} from "../components/card-stack";22} from "../components/card-stack";
@@ -41,18 +25,7 @@ import { · 2 unmodified lines
41 integrationInferredUrl,25 integrationInferredUrl,
42 integrationPresetIconUrl,26 integrationPresetIconUrl,
43} from "../components/integration-favicon";27} from "../components/integration-favicon";
44import { PresetIcon } from "../components/preset-icon";
45import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping";28import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping";
46import {
47 availableCatalogKinds,
48 catalogLogoUrl,
49 filterCatalogEntries,
50 presetDomains,
51 resolveConnectTarget,
52 useCatalogSearch,
53 type CatalogKind,
54 type CatalogSearchEntry,
55} from "../lib/integrations-sh-catalog";
56import { IntegrationHealthSummary } from "../components/integration-health-summary";29import { IntegrationHealthSummary } from "../components/integration-health-summary";
57import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";30import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";
58import { Skeleton } from "../components/skeleton";31import { Skeleton } from "../components/skeleton";
@@ -67,17 +40,6 @@ const KIND_TO_PLUGIN_KEY: Record<string, string> = { · 8 unmodified lines
67 googleDiscovery: "google",40 googleDiscovery: "google",
68};41};
69 42
70const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = {
71 high: 3,
72 medium: 2,
73 low: 1,
74};
75
76const bestDetection = (
77 results: readonly IntegrationDetectionResult[],
78): IntegrationDetectionResult | undefined =>
79 [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0];
80
81// ---------------------------------------------------------------------------43// ---------------------------------------------------------------------------
82// Page44// Page
83// ---------------------------------------------------------------------------45// ---------------------------------------------------------------------------
@@ -86,7 +48,6 @@ export function IntegrationsPage() { · 2 unmodified lines
86 useExecutorDocumentTitle("Integrations");48 useExecutorDocumentTitle("Integrations");
87 const integrations = useAtomValue(integrationsOptimisticAtom);49 const integrations = useAtomValue(integrationsOptimisticAtom);
88 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);50 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
89 const [connectOpen, setConnectOpen] = useState(false);
90 51
91 return (52 return (
92 <PageContainer>53 <PageContainer>
@@ -94,16 +55,14 @@ export function IntegrationsPage() { · 1 unmodified line
94 title="Integrations"55 title="Integrations"
95 description="Tool providers available in this workspace."56 description="Tool providers available in this workspace."
96 actions={57 actions={
97 <Button58 <Button asChild size="sm" className="gap-1.5">
98 onClick={() => {59 <Link
99 setConnectOpen(true);60 to="/{-$orgSlug}/integrations/browse"
100 trackEvent("integration_connect_dialog_opened");61 onClick={() => trackEvent("integration_browse_opened", { via: "header" })}
101 }}62 >
102 size="sm"63 <PlusIcon className="size-4" />
103 className="gap-1.5"64 Add integration
104 >65 </Link>
105 <PlusIcon className="size-4" />
106 Connect
107 </Button>66 </Button>
108 }67 }
109 />68 />
@@ -124,14 +83,7 @@ export function IntegrationsPage() { · 14 unmodified lines
124 ),83 ),
125 onSuccess: ({ value }) => {84 onSuccess: ({ value }) => {
126 if (value.length === 0) {85 if (value.length === 0) {
127 return (86 return <EmptyIntegrations />;
128 <EmptyIntegrations
129 onConnect={() => {
130 setConnectOpen(true);
131 trackEvent("integration_connect_dialog_opened");
132 }}
133 />
134 );
135 }87 }
136 88
137 return (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 </PageContainer>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.
158const 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
167export 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// Empty state102// Empty state
312// ---------------------------------------------------------------------------103// ---------------------------------------------------------------------------
313 104
314function EmptyIntegrations(props: { onConnect: () => void }) {105function EmptyIntegrations() {
315 return (106 return (
316 <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">107 <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">
317 <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">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 <p className="mb-5 text-[13px] text-muted-foreground/60">112 <p className="mb-5 text-[13px] text-muted-foreground/60">
322 Connect an integration to start curating tools.113 Connect an integration to start curating tools.
323 </p>114 </p>
324 <Button onClick={props.onConnect} size="sm" className="gap-1.5">115 <Button asChild size="sm" className="gap-1.5">
325 <PlusIcon className="size-4" />116 <Link
326 Connect an integration117 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 </Button>123 </Button>
328 </div>124 </div>
329 );125 );
330}126}
331 127
332// ---------------------------------------------------------------------------
333// Preset grid (for inside the Connect dialog)
334// ---------------------------------------------------------------------------
335
336type PresetEntry = {
337 preset: IntegrationPreset;
338 pluginKey: string;
339 pluginLabel: string;
340};
341
342const CATALOG_KIND_LABEL: Record<CatalogKind, string> = {
343 mcp: "MCP",
344 openapi: "OpenAPI",
345 graphql: "GraphQL",
346};
347
348function 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// Integration grid — flat list of catalog integrations, click-through to detail129// Integration grid — flat list of catalog integrations, click-through to detail
550// ---------------------------------------------------------------------------130// ---------------------------------------------------------------------------
packages/react/src/routes/integrations.add.$pluginKey.tsx+20−2
@@ -8,6 +8,14 @@ const SearchParams = Schema.toStandardSchemaV1( · 7 unmodified lines
8 url: Schema.optional(Schema.String),8 url: Schema.optional(Schema.String),
9 preset: Schema.optional(Schema.String),9 preset: Schema.optional(Schema.String),
10 namespace: Schema.optional(Schema.String),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 validateSearch: SearchParams,23 validateSearch: SearchParams,
16 component: () => {24 component: () => {
17 const { pluginKey } = Route.useParams();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 return (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});
packages/react/src/routes/integrations.browse.tsx+7−0
@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { IntegrationBrowsePage } from "../pages/integration-browse";
4
5export const Route = createFileRoute("/{-$orgSlug}/integrations/browse")({
6 component: IntegrationBrowsePage,
7});
packages/react/src/routes/routeTree.gen.ts+22−0
@@ -18,6 +18,7 @@ import { Route as DotPoliciesRouteImport } from './policies' · 17 unmodified lines
18import { Route as DotArtifactsRouteImport } from './artifacts'18import { Route as DotArtifactsRouteImport } from './artifacts'
19import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'19import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'
20import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'20import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'
21import { Route as DotIntegrationsDotbrowseRouteImport } from './integrations.browse'
21import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'22import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'
22import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug'23import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug'
23import { Route as DotArtifactsDotartifactIdRouteImport } from './artifacts.$artifactId'24import { Route as DotArtifactsDotartifactIdRouteImport } from './artifacts.$artifactId'
@@ -70,6 +71,12 @@ const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({ · 46 unmodified lines
70 path: '/{-$orgSlug}/resume/$executionId',71 path: '/{-$orgSlug}/resume/$executionId',
71 getParentRoute: () => rootRouteImport,72 getParentRoute: () => rootRouteImport,
72} as any)73} as any)
74const DotIntegrationsDotbrowseRoute =
75 DotIntegrationsDotbrowseRouteImport.update({
76 id: '/{-$orgSlug}/integrations/browse',
77 path: '/{-$orgSlug}/integrations/browse',
78 getParentRoute: () => rootRouteImport,
79 } as any)
73const DotIntegrationsDotnamespaceRoute =80const DotIntegrationsDotnamespaceRoute =
74 DotIntegrationsDotnamespaceRouteImport.update({81 DotIntegrationsDotnamespaceRouteImport.update({
75 id: '/{-$orgSlug}/integrations/$namespace',82 id: '/{-$orgSlug}/integrations/$namespace',
@@ -112,6 +119,7 @@ export interface FileRoutesByFullPath { · 36 unmodified lines
112 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute119 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute
113 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute120 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
114 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute121 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
122 '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute
115 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute123 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
116 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute124 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
117 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute125 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -128,6 +136,7 @@ export interface FileRoutesByTo { · 10 unmodified lines
128 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute136 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute
129 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute137 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
130 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute138 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
139 '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute
131 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute140 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
132 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute141 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
133 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute142 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -145,6 +154,7 @@ export interface FileRoutesById { · 11 unmodified lines
145 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute154 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute
146 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute155 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
147 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute156 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
157 '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute
148 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute158 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
149 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute159 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
150 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute160 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -163,6 +173,7 @@ export interface FileRouteTypes { · 12 unmodified lines
163 | '/{-$orgSlug}/artifacts/$artifactId'173 | '/{-$orgSlug}/artifacts/$artifactId'
164 | '/{-$orgSlug}/connect/$integrationSlug'174 | '/{-$orgSlug}/connect/$integrationSlug'
165 | '/{-$orgSlug}/integrations/$namespace'175 | '/{-$orgSlug}/integrations/$namespace'
176 | '/{-$orgSlug}/integrations/browse'
166 | '/{-$orgSlug}/resume/$executionId'177 | '/{-$orgSlug}/resume/$executionId'
167 | '/{-$orgSlug}/toolkits/$toolkitSlug'178 | '/{-$orgSlug}/toolkits/$toolkitSlug'
168 | '/{-$orgSlug}/integrations/add/$pluginKey'179 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -179,6 +190,7 @@ export interface FileRouteTypes { · 10 unmodified lines
179 | '/{-$orgSlug}/artifacts/$artifactId'190 | '/{-$orgSlug}/artifacts/$artifactId'
180 | '/{-$orgSlug}/connect/$integrationSlug'191 | '/{-$orgSlug}/connect/$integrationSlug'
181 | '/{-$orgSlug}/integrations/$namespace'192 | '/{-$orgSlug}/integrations/$namespace'
193 | '/{-$orgSlug}/integrations/browse'
182 | '/{-$orgSlug}/resume/$executionId'194 | '/{-$orgSlug}/resume/$executionId'
183 | '/{-$orgSlug}/toolkits/$toolkitSlug'195 | '/{-$orgSlug}/toolkits/$toolkitSlug'
184 | '/{-$orgSlug}/integrations/add/$pluginKey'196 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -195,6 +207,7 @@ export interface FileRouteTypes { · 10 unmodified lines
195 | '/{-$orgSlug}/artifacts/$artifactId'207 | '/{-$orgSlug}/artifacts/$artifactId'
196 | '/{-$orgSlug}/connect/$integrationSlug'208 | '/{-$orgSlug}/connect/$integrationSlug'
197 | '/{-$orgSlug}/integrations/$namespace'209 | '/{-$orgSlug}/integrations/$namespace'
210 | '/{-$orgSlug}/integrations/browse'
198 | '/{-$orgSlug}/resume/$executionId'211 | '/{-$orgSlug}/resume/$executionId'
199 | '/{-$orgSlug}/toolkits/$toolkitSlug'212 | '/{-$orgSlug}/toolkits/$toolkitSlug'
200 | '/{-$orgSlug}/integrations/add/$pluginKey'213 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -211,6 +224,7 @@ export interface RootRouteChildren { · 10 unmodified lines
211 DotIndexRoute: typeof DotIndexRoute224 DotIndexRoute: typeof DotIndexRoute
212 DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute225 DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute
213 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute226 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute
227 DotIntegrationsDotbrowseRoute: typeof DotIntegrationsDotbrowseRoute
214 DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute228 DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute
215 DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute229 DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute
216 DotPluginsDotpluginIdDotsplatRoute: typeof DotPluginsDotpluginIdDotsplatRoute230 DotPluginsDotpluginIdDotsplatRoute: typeof DotPluginsDotpluginIdDotsplatRoute
@@ -281,6 +295,13 @@ declare module '@tanstack/react-router' { · 64 unmodified lines
281 preLoaderRoute: typeof DotResumeDotexecutionIdRouteImport295 preLoaderRoute: typeof DotResumeDotexecutionIdRouteImport
282 parentRoute: typeof rootRouteImport296 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 '/{-$orgSlug}/integrations/$namespace': {305 '/{-$orgSlug}/integrations/$namespace': {
285 id: '/{-$orgSlug}/integrations/$namespace'306 id: '/{-$orgSlug}/integrations/$namespace'
286 path: '/{-$orgSlug}/integrations/$namespace'307 path: '/{-$orgSlug}/integrations/$namespace'
@@ -353,6 +374,7 @@ const rootRouteChildren: RootRouteChildren = { · 66 unmodified lines
353 DotIndexRoute: DotIndexRoute,374 DotIndexRoute: DotIndexRoute,
354 DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute,375 DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute,
355 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,376 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,
377 DotIntegrationsDotbrowseRoute: DotIntegrationsDotbrowseRoute,
356 DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute,378 DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute,
357 DotIntegrationsDotaddDotpluginKeyRoute:379 DotIntegrationsDotaddDotpluginKeyRoute:
358 DotIntegrationsDotaddDotpluginKeyRoute,380 DotIntegrationsDotaddDotpluginKeyRoute,
plans/kill-plugin-system.md+515−0
@@ -0,0 +1,515 @@
1# Kill the plugin system — in-place migration plan
2
3Decision (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,
6each keeping format/lint/typecheck/test/e2e green. Priority order: **data
7models first**, then the plugin kill, then the concept-package (apps) model,
8then the minimal SDK / platform split.
9
10RESEQUENCED (Rhys, 2026-07-28): **Phase 3 (apps/concept-package model) now
11lands BEFORE Phase 2 (plugin kill), side by side with the protocol plugins.**
12Rationale: the new model's tables are greenfield and additive — landing it
13first means the legacy apps tables never need an in-place migration; the whole
14model swaps underneath while openapi/mcp/graphql keep running as plugins.
15Structurally sound because (a) the kernel + MCP host are already plugin-free,
16so the new model's runtime never touched the registry, and (b) the apps plugin
17already exercises every extension point, so the new model ships as the apps
18plugin's new internals wearing the same costume. When 2.3 later converts the
19four kinds, apps converts as a stable, proven model instead of mid-rewrite.
20Phase 0 and 2.1 remain freely parallel — they don't touch apps.
21
22Hard line: side-by-side means new-apps-model alongside the _protocol plugins_,
23never two app models with dual read paths inside the apps kind. Existing app
24sources get a one-off migration to published-source form (enumerated
25invariants, explicit approval, no permanent compatibility path). Accepted
26cost: the new model's HTTP surface temporarily wears plugin-route ceremony
27(HttpApiGroup + handler layers) that 2.2/2.6 later deletes — wiring, not
28model. The authoring SDK (`defineApp`/`defineProvider`/`defineTool`) goes
29public early; its boundaries take Standard Schema (StandardSchemaV1 &
30StandardJSONSchemaV1), 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
36lines, ~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;
70its implementation ported only ~10 of 131 legacy e2e scenarios — that's why it
71failed, 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
841. **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.
902. **`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.
963. **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.
1034. **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.
1065. **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.
1096. **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.
1127. **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.
1158. **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
135Design 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
150PRs:
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
177Toolkits data does NOT move in this phase (it moves with its reader in 2.4 —
178moving it earlier means either rewriting the plugin storage layer twice or a
179forbidden dual read path).
180
181### Framing: product view vs platform view (Rhys, 2026-07-27)
182
183The 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
194Cloud wrinkle: cloud consumers like owner.com don't bring their own owner ids —
195their users authenticate through our WorkOS, so `subject` is _our_ accountId
196and the customer correlates on their side. The opaque-owner-ids-in-the-
197customer's-id-space story fully applies only to embedded/API consumers; for
198cloud, the platform view (+ `external_id` on the subject table) is what makes
199the correlation tractable.
200
201### Fast path: owner.com admin-visibility ask (ships from Phase 1 alone)
202
203The owner.com ask (admin inspects users' connections; API key that can do the
204same; available-vs-connected icon grid; direct connect links) needs only
2051.1 → 1.2 → 1.3 plus a thin delivery layer — no dependency on Phase 0 or the
206plugin 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,
261inside the apps plugin's existing costume; Phase 2 kills the registry
262afterward, with apps converting in 2.3 as an already-stable model.)
263
264Port the rework's app model in place as the `apps` source kind's new model:
265published source, manifest derived by execution, content-hashed immutable
266versions, rollback = pointer move. New tables are additive greenfield (same
267pattern as 1.1: coreTables + cloud mirror + migration, no readers first).
268Existing app sources migrate one-off to published-source form — no dual model
269within the apps kind. Then the OpenAPI/MCP generators + `GeneratorClaim` as an
270optional on-ramp (spec → generated app source); GraphQL generator later.
271Long-term option (explicitly out of scope): protocol kinds themselves become
272generators 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
277collections, sync state machine, publish/discover/descriptor, routes,
278console client). ~2.6k is model-agnostic and survives verbatim: all three
279sandbox executors, the bundler stack, bindings/bridge, authoring +
280standard-schema, conformance. ~1.9k (hand-rolled git client + source
281fetchers) hangs on the publish-input decision below. Storage is entirely
282generic `plugin_storage` rows + content-addressed blobs — zero apps-specific
283tables or migrations exist, so new tables are pure greenfield. Cloud prod is
284git-source-only; **source text is never persisted today (only compiled
285bundles)**, so the one-off migration re-fetches source from the recorded git
286URLs (fetcher exists). Load-bearing invariants to preserve: tool addresses
287`tools.<app>.org.published.<name>` + the `<fileSlug>__<key>` naming rule,
288app-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
291cloud+selfhost console loop), local directory-picker, packed-CLI, desktop
292e2e, ~2.9k LoC in-package tests. Pre-existing gaps flagged: R2 blobs never
293GC'd and org purge misses them; cloud silently falls back to in-process
294executor without the worker loader; host-cloudflare doesn't register apps.
295
296**Rework model** (portable tiers): T1 model proper ~3.9k LoC (authoring SDK,
297publish-by-execution, projection, generator verification, storage port,
298content hashing); T2 generators ~3.2k (fromOpenAPI 1k — complete; fromMCP
2990.6k — computed group with real streamable-HTTP client; json-schema engine;
300byte-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
303typed first-class alternative — matches our rule. Effect beta.93→beta.59
304delta is small (ConstraintDecoder → one-line fix; hand-roll
305StandardJSONSchemaV1 ~10 lines; reuse core sha256Hex). NOT portable as-is:
306rework `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
308model-owned logic: computed-surface cache + requirement resolution +
309runTool). Known rework gaps to fix at port time, not inherit: derive-time
310execution is unsandboxed in-process (`code-executor.ts:23` records it) — our
311port MUST run publish-time evaluation in the existing sandbox `collect` path
312from day one; fan-in computed groups unsupported; OAuth discovery
313unsupported.
314
315**Semantic deltas — decided by Rhys before 3.1 specs:**
316
3171. **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.
3272. **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.
3323. **App identity** moves from caller-supplied slug to the `defineApp`
333 export inside the source (rework shape).
3344. **Versioning**: content hash of source IS the version identity; the
335 caller-supplied `sourceRef` + descriptor v6 die with the old model.
3365. **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
366The rework's SDK has exactly one tenancy concept: `SpaceId`
367(`.reference/executor-rework/docs/TENANCY.md`, `docs/MODEL.md`). Adopt it as
368the 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,
397before 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
400is de facto the space — cloud writes the WorkOS org id there, self-host/local
401write 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
403first space is grandfathered with `spaceId = <historical tenant value>` (the
404org id); NEW spaces mint fresh ids written into the same column. The org→
405space mirror table records which space ids belong to which org (and carries
406the billing join — Autumn keys on workosOrgId, never a space). Existing rows
407never move; multi-space needs no schema change on scoped tables. "Space" is
408contract 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
445Apps (Phase 3) then lands after S1/S2 — app rows carry a real `space` FK
446from birth; S3/S4 can proceed in parallel with the apps port.
447
448Interaction with shipped Phase 1 work: the subject table and platform view
449are edge/deployment concepts (who exists in this identity world, what may
450an 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
455Direction: the SDK should be agnostic to the consumer's data-model shape, the
456way Composio's is — consumers pass an opaque user/owner id string and hang
457whatever hierarchy they have (user, team, workspace) off it; the SDK never
458models their org chart. The rework already designed this (`OwnerRef` opaque
459brand, `Reach = {owners}`, deployments translate identity at the edge) — adopt
460its vocabulary, not its schedule. "Tenant" remains a real _internal_ concept
461(which Executor-cloud org owns the rows) but must not leak into surfaces
462consumers touch.
463
464Rules, 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
487New consumer boundary modeled on the rework: `createExecutor` /
488`createRemoteExecutor`, no Effect at the boundary, `tools.list/execute` +
489connections + the new users/admin surface; policies/toolkits become optional
490layers consumers opt into, not baked opinions (Composio-style minimalism).
491
492Authorization end-state (Rhys, 2026-07-27): the engine enforces scope
493mechanically but never decides policy — the host computes, per request, the
494set of owners the request may read (the rework's `Reach = { owners }`), and
495the 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
497set shape here: "bound" = a reach of one, "tenant" = all owners (wildcard so
498cloud needn't enumerate), and in-between cases (team views, support sessions,
499subset-restricted org keys) are just different sets. Writes stay hard-pinned
500to a single owner; only read reach generalizes. Trust model becomes a
501per-deployment choice: a trusted backend with a server key gets tenant-wide
502reach and layers its own authorization (Composio-style); an end-user session
503gets a reach of one. The enum-to-set swap is contained: the policy context is
504constructed in exactly two places.
505Start the platform/product package separation _during_ 2.3–2.6 (each PR moves
506one thing to its final home) rather than as a big bang; the SDK boundary
507itself 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.