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
2424 import { Route as ApiKeysRouteImport } from './routes/app/api-keys'
2525 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
2626 import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'
27+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse'
2728 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
2829 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
2930 import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'
@@ -115,6 +116,14 @@ const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({ · 85 unmodified lines
115116 path: '/{-$orgSlug}/resume/$executionId',
116117 getParentRoute: () => rootRouteImport,
117118 } as any)
119+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute =
120+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update(
121+ {
122+ id: '/{-$orgSlug}/integrations/browse',
123+ path: '/{-$orgSlug}/integrations/browse',
124+ getParentRoute: () => rootRouteImport,
125+ } as any,
126+ )
118127 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =
119128 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(
120129 {
@@ -172,6 +181,7 @@ export interface FileRoutesByFullPath { · 51 unmodified lines
172181 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
173182 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
174183 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
184+ '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
175185 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
176186 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
177187 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -194,6 +204,7 @@ export interface FileRoutesByTo { · 16 unmodified lines
194204 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
195205 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
196206 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
207+ '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
197208 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
198209 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
199210 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -217,6 +228,7 @@ export interface FileRoutesById { · 17 unmodified lines
217228 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute
218229 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
219230 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
231+ '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
220232 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
221233 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
222234 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -241,6 +253,7 @@ export interface FileRouteTypes { · 18 unmodified lines
241253 | '/{-$orgSlug}/billing/plans'
242254 | '/{-$orgSlug}/connect/$integrationSlug'
243255 | '/{-$orgSlug}/integrations/$namespace'
256+ | '/{-$orgSlug}/integrations/browse'
244257 | '/{-$orgSlug}/resume/$executionId'
245258 | '/{-$orgSlug}/toolkits/$toolkitSlug'
246259 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -263,6 +276,7 @@ export interface FileRouteTypes { · 16 unmodified lines
263276 | '/{-$orgSlug}/billing/plans'
264277 | '/{-$orgSlug}/connect/$integrationSlug'
265278 | '/{-$orgSlug}/integrations/$namespace'
279+ | '/{-$orgSlug}/integrations/browse'
266280 | '/{-$orgSlug}/resume/$executionId'
267281 | '/{-$orgSlug}/toolkits/$toolkitSlug'
268282 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -285,6 +299,7 @@ export interface FileRouteTypes { · 16 unmodified lines
285299 | '/{-$orgSlug}/billing_/plans'
286300 | '/{-$orgSlug}/connect/$integrationSlug'
287301 | '/{-$orgSlug}/integrations/$namespace'
302+ | '/{-$orgSlug}/integrations/browse'
288303 | '/{-$orgSlug}/resume/$executionId'
289304 | '/{-$orgSlug}/toolkits/$toolkitSlug'
290305 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -307,6 +322,7 @@ export interface RootRouteChildren { · 16 unmodified lines
307322 Billing_DotplansRoute: typeof Billing_DotplansRoute
308323 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
309324 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
325+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
310326 ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute
311327 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
312328 }
@@ -418,6 +434,13 @@ declare module '@tanstack/react-router' { · 105 unmodified lines
418434 preLoaderRoute: typeof ResumeDotexecutionIdRouteImport
419435 parentRoute: typeof rootRouteImport
420436 }
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+ }
421444 '/{-$orgSlug}/integrations/$namespace': {
422445 id: '/{-$orgSlug}/integrations/$namespace'
423446 path: '/{-$orgSlug}/integrations/$namespace'
@@ -511,6 +534,8 @@ const rootRouteChildren: RootRouteChildren = { · 87 unmodified lines
511534 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
512535 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
513536 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
537+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute:
538+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute,
514539 ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute,
515540 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:
516541 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute,
@@ -520,11 +545,15 @@ export const routeTree = rootRouteImport · 3 unmodified lines
520545 ._addFileTypes<FileRouteTypes>()
521546
522547 import type { getRouter } from './router.tsx'
548+
523549 import type { startInstance } from './start.ts'
550+
524551 declare module '@tanstack/react-start' {
525552 interface Register {
526553 ssr: true
554+
527555 router: Awaited<ReturnType<typeof getRouter>>
556+
528557 config: Awaited<ReturnType<typeof startInstance.getOptions>>
529558 }
530559 }
apps/host-cloudflare/web/routeTree.gen.ts+25−0
@@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp · 16 unmodified lines
1717 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts'
1818 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
1919 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
20+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse'
2021 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
2122 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
2223 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId'
@@ -76,6 +77,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = · 53 unmodified lines
7677 getParentRoute: () => rootRouteImport,
7778 } as any,
7879 )
80+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute =
81+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update(
82+ {
83+ id: '/{-$orgSlug}/integrations/browse',
84+ path: '/{-$orgSlug}/integrations/browse',
85+ getParentRoute: () => rootRouteImport,
86+ } as any,
87+ )
7988 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =
8089 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(
8190 {
@@ -128,6 +137,7 @@ export interface FileRoutesByFullPath { · 46 unmodified lines
128137 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
129138 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
130139 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
140+ '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
131141 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
132142 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
133143 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -143,6 +153,7 @@ export interface FileRoutesByTo { · 9 unmodified lines
143153 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
144154 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
145155 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
156+ '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
146157 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
147158 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
148159 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -159,6 +170,7 @@ export interface FileRoutesById { · 10 unmodified lines
159170 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
160171 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
161172 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
173+ '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
162174 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
163175 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
164176 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -176,6 +188,7 @@ export interface FileRouteTypes { · 11 unmodified lines
176188 | '/{-$orgSlug}/artifacts/$artifactId'
177189 | '/{-$orgSlug}/connect/$integrationSlug'
178190 | '/{-$orgSlug}/integrations/$namespace'
191+ | '/{-$orgSlug}/integrations/browse'
179192 | '/{-$orgSlug}/resume/$executionId'
180193 | '/{-$orgSlug}/toolkits/$toolkitSlug'
181194 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -191,6 +204,7 @@ export interface FileRouteTypes { · 9 unmodified lines
191204 | '/{-$orgSlug}/artifacts/$artifactId'
192205 | '/{-$orgSlug}/connect/$integrationSlug'
193206 | '/{-$orgSlug}/integrations/$namespace'
207+ | '/{-$orgSlug}/integrations/browse'
194208 | '/{-$orgSlug}/resume/$executionId'
195209 | '/{-$orgSlug}/toolkits/$toolkitSlug'
196210 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -206,6 +220,7 @@ export interface FileRouteTypes { · 9 unmodified lines
206220 | '/{-$orgSlug}/artifacts/$artifactId'
207221 | '/{-$orgSlug}/connect/$integrationSlug'
208222 | '/{-$orgSlug}/integrations/$namespace'
223+ | '/{-$orgSlug}/integrations/browse'
209224 | '/{-$orgSlug}/resume/$executionId'
210225 | '/{-$orgSlug}/toolkits/$toolkitSlug'
211226 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -221,6 +236,7 @@ export interface RootRouteChildren { · 9 unmodified lines
221236 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
222237 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
223238 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
239+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
224240 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
225241 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
226242 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
@@ -284,6 +300,13 @@ declare module '@tanstack/react-router' { · 57 unmodified lines
284300 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport
285301 parentRoute: typeof rootRouteImport
286302 }
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+ }
287310 '/{-$orgSlug}/integrations/$namespace': {
288311 id: '/{-$orgSlug}/integrations/$namespace'
289312 path: '/{-$orgSlug}/integrations/$namespace'
@@ -369,6 +392,8 @@ const rootRouteChildren: RootRouteChildren = { · 79 unmodified lines
369392 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
370393 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
371394 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
395+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute:
396+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute,
372397 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:
373398 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute,
374399 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
2121 import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code'
2222 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
2323 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
24+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse'
2425 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
2526 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
2627 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId'
@@ -101,6 +102,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = · 74 unmodified lines
101102 getParentRoute: () => rootRouteImport,
102103 } as any,
103104 )
105+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute =
106+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update(
107+ {
108+ id: '/{-$orgSlug}/integrations/browse',
109+ path: '/{-$orgSlug}/integrations/browse',
110+ getParentRoute: () => rootRouteImport,
111+ } as any,
112+ )
104113 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =
105114 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(
106115 {
@@ -157,6 +166,7 @@ export interface FileRoutesByFullPath { · 50 unmodified lines
157166 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
158167 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
159168 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
169+ '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
160170 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
161171 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
162172 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -176,6 +186,7 @@ export interface FileRoutesByTo { · 13 unmodified lines
176186 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
177187 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
178188 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
189+ '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
179190 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
180191 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
181192 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -196,6 +207,7 @@ export interface FileRoutesById { · 14 unmodified lines
196207 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
197208 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
198209 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
210+ '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
199211 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
200212 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
201213 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -217,6 +229,7 @@ export interface FileRouteTypes { · 15 unmodified lines
217229 | '/{-$orgSlug}/artifacts/$artifactId'
218230 | '/{-$orgSlug}/connect/$integrationSlug'
219231 | '/{-$orgSlug}/integrations/$namespace'
232+ | '/{-$orgSlug}/integrations/browse'
220233 | '/{-$orgSlug}/resume/$executionId'
221234 | '/{-$orgSlug}/toolkits/$toolkitSlug'
222235 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -236,6 +249,7 @@ export interface FileRouteTypes { · 13 unmodified lines
236249 | '/{-$orgSlug}/artifacts/$artifactId'
237250 | '/{-$orgSlug}/connect/$integrationSlug'
238251 | '/{-$orgSlug}/integrations/$namespace'
252+ | '/{-$orgSlug}/integrations/browse'
239253 | '/{-$orgSlug}/resume/$executionId'
240254 | '/{-$orgSlug}/toolkits/$toolkitSlug'
241255 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -255,6 +269,7 @@ export interface FileRouteTypes { · 13 unmodified lines
255269 | '/{-$orgSlug}/artifacts/$artifactId'
256270 | '/{-$orgSlug}/connect/$integrationSlug'
257271 | '/{-$orgSlug}/integrations/$namespace'
272+ | '/{-$orgSlug}/integrations/browse'
258273 | '/{-$orgSlug}/resume/$executionId'
259274 | '/{-$orgSlug}/toolkits/$toolkitSlug'
260275 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -274,6 +289,7 @@ export interface RootRouteChildren { · 13 unmodified lines
274289 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
275290 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
276291 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
292+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
277293 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
278294 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
279295 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
@@ -365,6 +381,13 @@ declare module '@tanstack/react-router' { · 85 unmodified lines
365381 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport
366382 parentRoute: typeof rootRouteImport
367383 }
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+ }
368391 '/{-$orgSlug}/integrations/$namespace': {
369392 id: '/{-$orgSlug}/integrations/$namespace'
370393 path: '/{-$orgSlug}/integrations/$namespace'
@@ -455,6 +478,8 @@ const rootRouteChildren: RootRouteChildren = { · 84 unmodified lines
455478 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
456479 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
457480 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
481+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute:
482+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute,
458483 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:
459484 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute,
460485 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:
apps/local/package.json+1−1
@@ -10,7 +10,7 @@ · 9 unmodified lines
1010 "scripts": {
1111 "dev": "bun run dev:proxy && bun run dev:vite",
1212 "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",
1414 "build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build",
1515 "start": "bun run src/serve.ts",
1616 "db:generate": "drizzle-kit generate",
bun.lock+26−0
@@ -862,6 +862,30 @@ · 861 unmodified lines
862862 "vitest": "catalog:",
863863 },
864864 },
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+ },
865889 "packages/plugins/desktop-settings": {
866890 "name": "@executor-js/plugin-desktop-settings",
867891 "version": "1.6.5",
@@ -1804,6 +1828,8 @@ · 936 unmodified lines
18041828
18051829 "@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=="],
18061830
1831+ "@executor-js/onboarding-demo": ["@executor-js/onboarding-demo@workspace:packages/onboarding-demo"],
1832+
18071833 "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"],
18081834
18091835 "@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
7373
7474 await step("Open the integrations console", async () => {
7575 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();
7779 });
7880
7981 let faulted = 0;
e2e/scenarios/auth-methods-ui.test.ts+1−1
@@ -46,7 +46,7 @@ scenario( · 45 unmodified lines
4646 });
4747
4848 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();
5050 });
5151
5252 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
2626
2727 import { scenario } from "../src/scenario";
2828 import { Api, Browser, Target } from "../src/services";
29−import { clickToReveal, visit } from "../src/surfaces/browser";
29+import { visit } from "../src/surfaces/browser";
3030
3131 const api = composePluginApi([openApiHttpPlugin()] as const);
3232
@@ -181,35 +181,10 @@ scenario( · 148 unmodified lines
181181 const client = yield* makeApiClient(api, identity);
182182
183183 yield* browser.session(identity, async ({ page, step }) => {
184− await step("Open the Google integration catalog", async () => {
185− await visit(page, "/integrations");
186− const dialog = page.getByRole("dialog", { name: "Connect an integration" });
187− await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog);
184+ await step("Open the integrations registry browser", async () => {
185+ await visit(page, "/integrations/browse");
186+ await page.getByPlaceholder(/Search integrations, or paste a URL/).waitFor();
188187 });
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− }
213188 });
214189
215190 // 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
117117 const addGooglePresetFromCatalog = (
118118 browser: BrowserSurface,
119119 identity: Identity,
120+ presetId: string,
120121 presetName: string,
121122 slug: string,
122123 ) =>
123124 browser.session(identity, async ({ page, step }) => {
124− await step(`Open ${presetName} from the connect catalog`, async () => {
125− await page.goto("/integrations", { waitUntil: "networkidle" });
126− await page
127− .getByRole("button", { name: /Connect/ })
128− .first()
129− .click();
130− const dialog = page.getByRole("dialog", { name: "Connect an integration" });
131− await dialog.waitFor();
132− await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName);
133− await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click();
125+ await step(`Open the ${presetName} preset's add flow`, async () => {
126+ // The connect dialog became the full-page picker, and registry-listed
127+ // provider presets no longer render cards there — the preset deep link
128+ // is the stable route to a preset-configured add flow.
129+ await page.goto(`/integrations/add/openapi?preset=${presetId}`, {
130+ waitUntil: "networkidle",
131+ });
134132 });
135133
136134 await step(`Add the ${presetName} integration`, async () => {
137− await page.waitForURL(/\/integrations\/add\/openapi/);
138135 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
139136 const button = page.getByRole("button", { name: "Add integration" });
140137 await button.waitFor({ timeout: 120_000 });
@@ -218,7 +215,7 @@ scenario( · 77 unmodified lines
218215
219216 yield* Effect.ensuring(
220217 Effect.gen(function* () {
221− yield* addGooglePresetFromCatalog(browser, identity, "Gmail", String(slug));
218+ yield* addGooglePresetFromCatalog(browser, identity, "google-gmail", "Gmail", String(slug));
222219
223220 const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
224221 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
1717 import { scenario } from "../src/scenario";
1818 import { Api, Browser, Target } from "../src/services";
1919 import type { Identity, Target as TargetShape } from "../src/target";
20−import { type BrowserSurface, clickToReveal, visit } from "../src/surfaces/browser";
20+import { type BrowserSurface, visit } from "../src/surfaces/browser";
2121
2222 const api = composePluginApi([openApiHttpPlugin()] as const);
2323 type Client = HttpApiClient.ForApi<typeof api>;
2424 const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2");
2525 const CONNECTION = ConnectionName.make("main");
2626 const GOOGLE_EMULATOR_ACCOUNT_EMAIL = "testuser@gmail.com";
27+const googleDiscoveryUrl = (service: string, version: string) =>
28+ `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`;
2729
2830 const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
2931
@@ -79,19 +81,18 @@ const createGoogleEmulator = Effect.gen(function* () { · 49 unmodified lines
7981 return { client, baseUrl };
8082 });
8183
82−const addGooglePresetFromCatalog = (
84+const addGooglePresetFromDirectUrl = (
8385 browser: BrowserSurface,
8486 identity: Identity,
8587 presetName: string,
88+ presetId: string,
89+ presetUrl: string,
8690 slug: string,
8791 ) =>
8892 browser.session(identity, async ({ page, step }) => {
89− await step(`Open ${presetName} from the connect catalog`, async () => {
90− await visit(page, "/integrations");
91− const dialog = page.getByRole("dialog", { name: "Connect an integration" });
92− await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog);
93− await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName);
94− await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click();
93+ await step(`Open the ${presetName} preset add flow directly`, async () => {
94+ const search = new URLSearchParams({ preset: presetId, url: presetUrl });
95+ await visit(page, `/integrations/add/openapi?${search}`);
9596 });
9697
9798 await step(`Add the ${presetName} integration`, async () => {
@@ -201,6 +202,8 @@ scenario( · 103 unmodified lines
201202 const rows = [
202203 {
203204 presetName: "Google Calendar",
205+ presetId: "google-calendar",
206+ presetUrl: googleDiscoveryUrl("calendar", "v3"),
204207 slug: IntegrationSlug.make("google_calendar"),
205208 oauthClient: OAuthClientSlug.make(unique("google_calendar_oauth")),
206209 expectedHealthOperation: "calendar.calendarList.list",
@@ -209,6 +212,8 @@ scenario( · 2 unmodified lines
209212 },
210213 {
211214 presetName: "Gmail",
215+ presetId: "google-gmail",
216+ presetUrl: googleDiscoveryUrl("gmail", "v1"),
212217 slug: IntegrationSlug.make("google_gmail"),
213218 oauthClient: OAuthClientSlug.make(unique("google_gmail_oauth")),
214219 expectedHealthOperation: "gmail.users.labels.list",
@@ -220,7 +225,14 @@ scenario( · 5 unmodified lines
220225 yield* Effect.ensuring(
221226 Effect.gen(function* () {
222227 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+ );
224236
225237 const stored = yield* client.integrations.healthCheckGet({ params: { slug: row.slug } });
226238 expect(stored?.operation, `${row.presetName} stored health check`).toBe(
@@ -324,7 +336,14 @@ scenario( · 97 unmodified lines
324336
325337 yield* Effect.ensuring(
326338 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+ );
328347
329348 const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
330349 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
33
44 import { scenario } from "../src/scenario";
55 import { Browser, Target } from "../src/services";
6−import { clickToReveal, visit } from "../src/surfaces/browser";
6+import { visit } from "../src/surfaces/browser";
77
8+const librarySpecUrl = "https://integrations.sh/specs/google/google-photos-library.json";
9+const pickerSpecUrl = "https://integrations.sh/specs/google/google-photos-picker.json";
10+
11+// Photos Library and Photos Picker are independent registry products. The
12+// picker must preserve that distinction all the way into the selected add flow.
813 scenario(
9− "Google Photos: separated catalog presets open a Photos service add flow",
14+ "Google Photos products are separate registry cards with separate add flows",
1015 {},
1116 Effect.gen(function* () {
1217 const target = yield* Target;
@@ -14,30 +19,100 @@ scenario( · 1 unmodified line
1419 const identity = yield* target.newIdentity();
1520
1621 yield* browser.session(identity, async ({ page, step }) => {
17− await step(
18− "Find the separated Google Photos presets from the integrations picker",
19− async () => {
20− await visit(page, "/integrations");
21− const dialog = page.getByRole("dialog", { name: "Connect an integration" });
22− await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog);
23− await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos");
24− await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).waitFor();
25− await dialog.getByRole("link", { name: /^Google Photos Picker\b/ }).waitFor();
26− },
27− );
22+ await step("Stub the two Google Photos registry products", async () => {
23+ await page.route("https://integrations.sh/api/search*", (route) =>
24+ route.fulfill({
25+ contentType: "application/json",
26+ headers: { "access-control-allow-origin": "*" },
27+ json: {
28+ results: [
29+ {
30+ domain: "photos.google.com",
31+ name: "Google Photos Library",
32+ description: "Create and manage app-created photos.",
33+ kinds: ["openapi"],
34+ url: "https://integrations.sh/photos.google.com/",
35+ },
36+ {
37+ domain: "photos.google.com",
38+ name: "Google Photos Picker",
39+ description: "Let people select photos for an app.",
40+ kinds: ["openapi"],
41+ url: "https://integrations.sh/photos.google.com/",
42+ },
43+ ],
44+ },
45+ }),
46+ );
47+ await page.route("https://integrations.sh/api/photos.google.com/surface", (route) =>
48+ route.fulfill({
49+ contentType: "application/json",
50+ headers: { "access-control-allow-origin": "*" },
51+ json: {
52+ version: 3,
53+ domain: "photos.google.com",
54+ surfaces: [
55+ { type: "http", slug: "google-photos-library", spec: librarySpecUrl },
56+ { type: "http", slug: "google-photos-picker", spec: pickerSpecUrl },
57+ ],
58+ },
59+ }),
60+ );
61+ });
62+
63+ await step("Search for Photos and see two separate product cards", async () => {
64+ await visit(page, "/integrations/browse");
65+ await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("photos");
66+ const cards = page.getByTestId("catalog-photos.google.com-openapi");
67+ await cards.filter({ hasText: "Google Photos Library API" }).waitFor();
68+ await cards.filter({ hasText: "Google Photos Picker API" }).waitFor();
69+ });
2870
29− await step("Open the Google Photos Library add flow", async () => {
30− const dialog = page.getByRole("dialog", { name: "Connect an integration" });
31− await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).click();
32− await page.waitForURL(/\/integrations\/add\/openapi/);
33− await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
71+ await step("Add Photos Library in place; the Picker card is untouched", async () => {
72+ await page
73+ .getByTestId("catalog-photos.google.com-openapi")
74+ .filter({ hasText: "Google Photos Library API" })
75+ .getByRole("button", { name: "Add Google Photos Library API" })
76+ .click();
77+ const libraryCard = page
78+ .getByTestId("catalog-photos.google.com-openapi")
79+ .filter({ hasText: "Google Photos Library API" });
80+ await libraryCard
81+ .getByRole("link", { name: "View Google Photos Library API" })
82+ .waitFor({ timeout: 120_000 });
83+ expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/);
84+ // Product identity survived: the LIBRARY slug became the namespace…
85+ const href = await libraryCard
86+ .getByRole("link", { name: "View Google Photos Library API" })
87+ .getAttribute("href");
88+ expect(href).toContain("/integrations/google_photos_library");
89+ // …and the Picker card, same domain and kind, still offers Add.
90+ await page
91+ .getByTestId("catalog-photos.google.com-openapi")
92+ .filter({ hasText: "Google Photos Picker API" })
93+ .getByRole("button", { name: "Add Google Photos Picker API" })
94+ .waitFor();
3495 });
3596
36− await step("The Library link carries the focused OpenAPI preset", async () => {
37− const url = new URL(page.url());
38− expect(url.searchParams.get("preset")).toBe("google-photos-library");
39− expect(url.searchParams.get("url")).toContain("photoslibrary");
40− await expect.poll(() => page.locator("textarea").inputValue()).toContain("photoslibrary");
97+ await step("Adding the SECOND same-kind card keeps its own identity", async () => {
98+ // The regression this guards: selection re-derived the surface by
99+ // KIND, so the second product of a kind inherited the first's slug,
100+ // auth, and overrides.
101+ await page
102+ .getByTestId("catalog-photos.google.com-openapi")
103+ .filter({ hasText: "Google Photos Picker API" })
104+ .getByRole("button", { name: "Add Google Photos Picker API" })
105+ .click();
106+ const pickerCard = page
107+ .getByTestId("catalog-photos.google.com-openapi")
108+ .filter({ hasText: "Google Photos Picker API" });
109+ await pickerCard
110+ .getByRole("link", { name: "View Google Photos Picker API" })
111+ .waitFor({ timeout: 120_000 });
112+ const href = await pickerCard
113+ .getByRole("link", { name: "View Google Photos Picker API" })
114+ .getAttribute("href");
115+ expect(href).toContain("/integrations/google_photos_picker");
41116 });
42117 });
43118 }),
e2e/scenarios/integrations-catalog-search.test.ts+103−64
@@ -1,77 +1,116 @@
1+import { randomBytes } from "node:crypto";
2+
13 import { expect } from "@effect/vitest";
24 import { Effect } from "effect";
5+import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
36
47 import { scenario } from "../src/scenario";
58 import { Browser, Target } from "../src/services";
6−import { clickToReveal, visit } from "../src/surfaces/browser";
9+import { visit } from "../src/surfaces/browser";
710
8−// The connect dialog's long-tail search goes to the public integrations.sh
9−// registry from the browser. CI must not depend on the live service, so both
10−// registry endpoints are fulfilled at the network layer here — including the
11−// CORS header a real cross-origin browser call needs.
11+// The picker's registry search goes to the public integrations.sh service from
12+// the browser. CI must not depend on the live service, so the search endpoint
13+// is fulfilled at the network layer here — including the CORS header a real
14+// cross-origin browser call needs. The MCP endpoints themselves are NOT
15+// stubbed: the quick add probes and registers server-side, so the working row
16+// points at a local fixture server and the broken row at a reserved-TLD host.
1217 scenario(
13− "Connect dialog: integrations.sh catalog search resolves into a prefilled add flow",
18+ "Add page: a registry row adds in place, and falls back to the config screen",
1419 {},
15− Effect.gen(function* () {
16− const target = yield* Target;
17− const browser = yield* Browser;
18− const identity = yield* target.newIdentity();
20+ Effect.scoped(
21+ Effect.gen(function* () {
22+ const target = yield* Target;
23+ const browser = yield* Browser;
24+ // Unique per run — the derived namespace must not collide on targets
25+ // whose identities share one tenant (selfhost admin).
26+ const suffix = randomBytes(3).toString("hex");
27+ const server = yield* serveMcpServer(() =>
28+ makeGreetingMcpServer({ name: `catalog-quick-${suffix}` }),
29+ );
30+ const identity = yield* target.newIdentity();
1931
20− yield* browser.session(identity, async ({ page, step }) => {
21− await step("Stub the integrations.sh registry endpoints", async () => {
22− await page.route("https://integrations.sh/api/search*", (route) =>
23− route.fulfill({
24− contentType: "application/json",
25− headers: { "access-control-allow-origin": "*" },
26− json: {
27− results: [
28− {
29− domain: "todoist.com",
30− name: "todoist.com",
31− description: "Tasks, projects, and collaboration.",
32− kinds: ["mcp", "cli"],
33− url: "https://integrations.sh/todoist.com/",
34− },
35− ],
36− },
37− }),
38− );
39− await page.route("https://integrations.sh/api/todoist.com/surface", (route) =>
40− route.fulfill({
41− contentType: "application/json",
42− headers: { "access-control-allow-origin": "*" },
43− json: {
44− version: 3,
45− domain: "todoist.com",
46− surfaces: [
47− { type: "mcp", url: "https://ai.todoist.net/mcp", slug: "todoist" },
48− { type: "cli", slug: "todoist-cli" },
49− ],
50− },
51− }),
52− );
53− });
32+ yield* browser.session(identity, async ({ page, step }) => {
33+ await step("Stub the integrations.sh search endpoint", async () => {
34+ await page.route("https://integrations.sh/api/search*", (route) =>
35+ route.fulfill({
36+ contentType: "application/json",
37+ headers: { "access-control-allow-origin": "*" },
38+ json: {
39+ results: [
40+ {
41+ domain: "todoist.com",
42+ name: "todoist.com",
43+ description: "Tasks, projects, and collaboration.",
44+ kinds: ["mcp", "cli"],
45+ surfaces: [
46+ { kind: "mcp", slug: `todoist-${suffix}`, url: server.endpoint },
47+ { kind: "cli", slug: "todoist-cli" },
48+ ],
49+ },
50+ {
51+ domain: "deadserver.com",
52+ name: "deadserver.com",
53+ description: "A vendor whose MCP endpoint is gone.",
54+ kinds: ["mcp"],
55+ surfaces: [
56+ { kind: "mcp", slug: "deadserver", url: "https://mcp.notareal.invalid/mcp" },
57+ ],
58+ },
59+ ],
60+ },
61+ }),
62+ );
63+ });
5464
55− await step("Searching surfaces the catalog row under the presets", async () => {
56− await visit(page, "/integrations");
57− const dialog = page.getByRole("dialog", { name: "Connect an integration" });
58− await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog);
59− await dialog.getByPlaceholder(/Search or paste a URL/).fill("todoist");
60− // The CLI-only surface is not offered; the connectable kind is.
61− await dialog
62− .getByRole("button", { name: /todoist\.com/ })
63− .getByText("MCP")
64− .waitFor();
65− });
65+ await step("Searching surfaces the connectable rows, not the CLI", async () => {
66+ await visit(page, "/integrations/browse");
67+ await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist");
68+ await page.getByTestId(`catalog-todoist-${suffix}`).getByText("Todoist MCP").waitFor();
69+ await page.getByTestId("catalog-deadserver").waitFor();
70+ expect(await page.getByTestId("catalog-todoist-cli").count()).toBe(0);
71+ });
72+
73+ await step("Add registers in place; the card flips to View", async () => {
74+ await page
75+ .getByTestId(`catalog-todoist-${suffix}`)
76+ .getByRole("button", { name: "Add Todoist MCP" })
77+ .click();
78+ // Generous timeout: the quick add probes the fixture server and
79+ // registers, all behind one click, and the dev server can queue
80+ // under CI load.
81+ await page
82+ .getByTestId(`catalog-todoist-${suffix}`)
83+ .getByRole("link", { name: "View Todoist MCP" })
84+ .waitFor({ timeout: 90_000 });
85+ // The whole point: the user never left the picker.
86+ expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/);
87+ });
88+
89+ await step("View jumps to the integration's hub", async () => {
90+ await page
91+ .getByTestId(`catalog-todoist-${suffix}`)
92+ .getByRole("link", { name: "View Todoist MCP" })
93+ .click();
94+ await page.waitForURL(new RegExp(`/integrations/todoist_${suffix}`));
95+ await page.goBack();
96+ await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist");
97+ await page.getByTestId("catalog-deadserver").waitFor();
98+ });
6699
67− await step("Picking the row lands on the MCP add flow, prefilled", async () => {
68− const dialog = page.getByRole("dialog", { name: "Connect an integration" });
69− await dialog.getByRole("button", { name: /todoist\.com/ }).click();
70− await page.waitForURL(/\/integrations\/add\/mcp/);
71− const url = new URL(page.url());
72− expect(url.searchParams.get("url")).toBe("https://ai.todoist.net/mcp");
73− expect(url.searchParams.get("namespace")).toBe("todoist");
100+ await step("An unreachable endpoint falls back to the config screen", async () => {
101+ await page
102+ .getByTestId("catalog-deadserver")
103+ .getByRole("button", { name: "Add Deadserver MCP" })
104+ .click();
105+ // Quick add cannot register it, so the click lands on the add flow
106+ // prefilled with the same facts — where the probe failure renders
107+ // with retry UX.
108+ await page.waitForURL(/\/integrations\/add\/mcp/, { timeout: 90_000 });
109+ const url = new URL(page.url());
110+ expect(url.searchParams.get("url")).toBe("https://mcp.notareal.invalid/mcp");
111+ expect(url.searchParams.get("namespace")).toBe("deadserver");
112+ });
74113 });
75− });
76− }),
114+ }),
115+ ),
77116 );
e2e/scenarios/mcp-catalog-sync-ui.test.ts+1−1
@@ -40,7 +40,7 @@ scenario( · 39 unmodified lines
4040 // The URL auto-probes (debounced); the method list appears once the
4141 // probe lands — an open server seeds the detected no-auth method.
4242 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();
4444 });
4545
4646 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
33
44 import { scenario } from "../src/scenario";
55 import { Browser, Target } from "../src/services";
6−import { clickToReveal, visit } from "../src/surfaces/browser";
6+import { visit } from "../src/surfaces/browser";
77
8+const gmailSpecUrl = "https://integrations.sh/specs/google/google-gmail.json";
9+const outlookSpecUrl = "https://integrations.sh/specs/microsoft-graph/mail.json";
10+
11+// Provider products are ordinary integrations.sh results now. Stub both the
12+// search response and the per-domain surface documents so this scenario proves
13+// the full registry-card-to-add-flow journey without depending on the service.
814 scenario(
9− "Provider catalog · Google and Microsoft services are OpenAPI presets",
15+ "Google and Microsoft products are registry cards that resolve into prefilled add flows",
1016 {},
1117 Effect.gen(function* () {
1218 const target = yield* Target;
@@ -14,58 +20,91 @@ scenario( · 1 unmodified line
1420 const identity = yield* target.newIdentity();
1521
1622 yield* browser.session(identity, async ({ page, step }) => {
17− await step("Open the integrations picker from the sidebar", async () => {
18− await visit(page, "/policies");
19− await clickToReveal(
20− page
21− .getByRole("navigation")
22− .getByRole("button", { name: "Browse integrations", exact: true }),
23− page.getByRole("dialog", { name: "Connect an integration" }),
23+ await step("Stub the Google and Microsoft registry products", async () => {
24+ await page.route("https://integrations.sh/api/search*", (route) =>
25+ route.fulfill({
26+ contentType: "application/json",
27+ headers: { "access-control-allow-origin": "*" },
28+ json: {
29+ results: [
30+ {
31+ domain: "gmail.com",
32+ name: "Gmail",
33+ description: "Email from Google.",
34+ kinds: ["openapi"],
35+ url: "https://integrations.sh/gmail.com/",
36+ },
37+ {
38+ domain: "graph.microsoft.com",
39+ name: "Outlook Mail",
40+ description: "Email from Microsoft.",
41+ kinds: ["openapi"],
42+ url: "https://integrations.sh/graph.microsoft.com/",
43+ },
44+ ],
45+ },
46+ }),
47+ );
48+ await page.route("https://integrations.sh/api/gmail.com/surface", (route) =>
49+ route.fulfill({
50+ contentType: "application/json",
51+ headers: { "access-control-allow-origin": "*" },
52+ json: {
53+ version: 3,
54+ domain: "gmail.com",
55+ surfaces: [{ type: "http", slug: "google-gmail", spec: gmailSpecUrl }],
56+ },
57+ }),
58+ );
59+ await page.route("https://integrations.sh/api/graph.microsoft.com/surface", (route) =>
60+ route.fulfill({
61+ contentType: "application/json",
62+ headers: { "access-control-allow-origin": "*" },
63+ json: {
64+ version: 3,
65+ domain: "graph.microsoft.com",
66+ surfaces: [{ type: "http", slug: "outlook-mail", spec: outlookSpecUrl }],
67+ },
68+ }),
2469 );
2570 });
2671
27− await step("The picker exposes OpenAPI plus provider service presets", async () => {
28− const dialog = page.getByRole("dialog", {
29− name: "Connect an integration",
30− });
31− const search = dialog.getByPlaceholder(/Search or paste a URL/);
32− await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor();
33−
72+ await step("Search for Gmail and see its registry card and domain", async () => {
73+ await visit(page, "/integrations/browse");
74+ const search = page.getByPlaceholder(/Search integrations, or paste a URL/);
3475 await search.fill("gmail");
35− await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor();
36−
37− await search.fill("onedrive");
38− await dialog.getByRole("link", { name: /^OneDrive Files\b/ }).waitFor();
76+ const card = page.getByTestId("catalog-gmail.com-openapi");
77+ await card.waitFor();
78+ await card.getByText("gmail.com").waitFor();
3979 });
4080
41− await step("OpenAPI add remains generic", async () => {
42− await page.goto("/integrations/add/openapi", {
43− waitUntil: "domcontentloaded",
44− });
45− await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
46− await page.getByText("OpenAPI Spec").waitFor();
47− expect(await page.getByText("Customize your Google connection").count()).toBe(0);
48− expect(await page.getByText("Customize Microsoft Graph").count()).toBe(0);
81+ await step("Add Gmail in place: the card flips to View", async () => {
82+ await page
83+ .getByTestId("catalog-gmail.com-openapi")
84+ .getByRole("button", { name: "Add Gmail API" })
85+ .click();
86+ // The quick add fetches the hosted spec server-side and registers —
87+ // no navigation. Generous timeout: a real spec fetch + parse rides
88+ // behind the click.
89+ await page
90+ .getByTestId("catalog-gmail.com-openapi")
91+ .getByRole("link", { name: "View Gmail API" })
92+ .waitFor({ timeout: 120_000 });
93+ expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/);
94+ // The registry surface slug became the namespace.
95+ const href = await page
96+ .getByTestId("catalog-gmail.com-openapi")
97+ .getByRole("link", { name: "View Gmail API" })
98+ .getAttribute("href");
99+ expect(href).toContain("/integrations/google_gmail");
49100 });
50101
51− await step("A Google service preset opens the OpenAPI add flow", async () => {
52− await page.goto(
53− "/integrations/add/openapi?preset=google-gmail&url=https%3A%2F%2Fwww.googleapis.com%2Fdiscovery%2Fv1%2Fapis%2Fgmail%2Fv1%2Frest",
54− { waitUntil: "domcontentloaded" },
55− );
56− await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
57− await expect.poll(() => page.locator("textarea").inputValue()).toContain("gmail");
58− });
59−
60− await step("A Microsoft service preset opens the OpenAPI add flow", async () => {
61− await page.goto(
62− "/integrations/add/openapi?preset=microsoft-files&url=https%3A%2F%2Fgithub.com%2FUsefulSoftwareCo%2Fexecutor%2Freleases%2Fdownload%2Fgraph-slices%2Ffiles.yaml",
63− { waitUntil: "domcontentloaded" },
64− );
65− await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
66− await expect
67− .poll(() => page.locator("textarea").inputValue())
68− .toContain("graph-slices/files.yaml");
102+ await step("Search for Outlook and see its separate registry card and domain", async () => {
103+ await visit(page, "/integrations/browse");
104+ await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("outlook");
105+ const card = page.getByTestId("catalog-graph.microsoft.com-openapi");
106+ await card.waitFor();
107+ await card.getByText("graph.microsoft.com").waitFor();
69108 });
70109 });
71110 }),
e2e/selfhost/auth-methods-ui.test.ts+1−1
@@ -169,7 +169,7 @@ scenario( · 168 unmodified lines
169169 });
170170
171171 await step("The probe detected OAuth", async () => {
172− await page.getByText("Method 1 · Detected").waitFor();
172+ await page.getByText("OAuth · Detected").waitFor();
173173 // The OAuth editor declares discovery-at-connect, not pasted URLs.
174174 await page.getByText("OAuth metadata is discovered from this server").waitFor();
175175 });
e2e/selfhost/detected-auth-immutable-ui.test.ts+15−14
@@ -1,6 +1,6 @@
11 // Selfhost-only (browser): a spec/probe-DETECTED auth method is immutable in
22 // the add flow. The shared AuthMethodListEditor renders detected methods as a
3−// disabled, read-only summary ("Pulled from spec. Remove to override.") with no
3+// disabled, read-only summary (declared-by-this-API footer) with no
44 // kind selector, so a user can't silently retype the spec's method into a kind
55 // nothing backs. A method the user adds by hand stays fully editable. Both the
66 // 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
1818 import { Browser, Target } from "../src/services";
1919 import { visit } from "../src/surfaces/browser";
2020
21−const REMOVE_HINT = "Pulled from spec. Remove to override.";
21+// The detected-method footer names the source and what happens next; the
22+// stable common prefix is the assertion target.
23+const REMOVE_HINT = "Declared by this API.";
2224
2325 scenario(
2426 "Detected auth · an MCP probe's OAuth method is immutable in the add flow",
@@ -39,20 +41,16 @@ scenario( · 14 unmodified lines
3941 await step("Open the add-MCP flow pointed at the OAuth server", async () => {
4042 await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`);
4143 await page.getByText("How does this server authenticate?").waitFor();
42− await page.getByText("Method 1 · Detected").waitFor();
44+ await page.getByText("OAuth · Detected").waitFor();
4345 });
4446
4547 await step("The detected method is locked: read-only, named, no selector", async () => {
46− // The kind is named explicitly ("OAuth"), the discovered-OAuth summary
47− // and override hint sit inside a disabled block, and there is NO
48− // editable kind selector (the FilterTabs render as buttons).
49− await page.getByText("OAuth", { exact: true }).first().waitFor();
48+ // The kind is named in the row TITLE ("OAuth · Detected", asserted
49+ // above), the discovered-OAuth summary and override hint render
50+ // read-only, and there is NO editable kind selector (the FilterTabs
51+ // render as buttons).
5052 await page.getByText("OAuth metadata is discovered from this server").waitFor();
5153 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);
5654 expect(
5755 await page.getByRole("button", { name: "API key", exact: true }).count(),
5856 "no editable kind selector is shown for the detected method",
@@ -150,7 +148,9 @@ scenario( · 91 unmodified lines
150148 .first()
151149 .fill(spec.url);
152150 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();
154154 });
155155
156156 await step("Both detected methods are locked, named, read-only", async () => {
@@ -162,8 +162,9 @@ scenario( · 5 unmodified lines
162162 "both detected methods show the remove-to-override hint",
163163 ).toBe(2);
164164 await page.getByText("https://api.acme.test/oauth/authorize").waitFor();
165− await page.getByText("API key", { exact: true }).first().waitFor();
166− await page.getByText("OAuth", { exact: true }).first().waitFor();
165+ // Kinds are named in the row TITLES ("API key · …" / "OAuth · …").
166+ await page.getByText("API key · ").first().waitFor();
167+ await page.getByText("OAuth · ").first().waitFor();
167168 expect(
168169 await page.getByRole("button", { name: "OAuth", exact: true }).count(),
169170 "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
6161 });
6262
6363 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();
6565 // The preview card flags the gate rather than failing the probe.
6666 await page.getByText("Auth required").first().waitFor();
6767 });
e2e/selfhost/posthog-mcp-oauth.test.ts+1−1
@@ -51,7 +51,7 @@ scenario( · 50 unmodified lines
5151 addUrl.searchParams.set("url", server.endpoint);
5252 await visit(page, addUrl.toString());
5353 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();
5555 await page.getByText("OAuth metadata is discovered from this server").waitFor();
5656 });
5757
package.json+1−0
@@ -24,6 +24,7 @@ · 23 unmodified lines
2424 "packages/*/*",
2525 "packages/react",
2626 "packages/app",
27+ "packages/onboarding-demo",
2728 "apps/*",
2829 "examples/*",
2930 "e2e"
packages/app/src/routeTree.gen.ts+23−0
@@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './ · 16 unmodified lines
1717 import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts'
1818 import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'
1919 import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'
20+import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../react/src/routes/integrations.browse'
2021 import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'
2122 import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug'
2223 import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId'
@@ -70,6 +71,12 @@ const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute = · 47 unmodified lines
7071 path: '/{-$orgSlug}/resume/$executionId',
7172 getParentRoute: () => rootRouteImport,
7273 } as any)
74+const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute =
75+ DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport.update({
76+ id: '/{-$orgSlug}/integrations/browse',
77+ path: '/{-$orgSlug}/integrations/browse',
78+ getParentRoute: () => rootRouteImport,
79+ } as any)
7380 const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute =
7481 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport.update({
7582 id: '/{-$orgSlug}/integrations/$namespace',
@@ -113,6 +120,7 @@ export interface FileRoutesByFullPath { · 37 unmodified lines
113120 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute
114121 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
115122 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
123+ '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
116124 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
117125 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
118126 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -128,6 +136,7 @@ export interface FileRoutesByTo { · 9 unmodified lines
128136 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute
129137 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
130138 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
139+ '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
131140 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
132141 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
133142 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -144,6 +153,7 @@ export interface FileRoutesById { · 10 unmodified lines
144153 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute
145154 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
146155 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
156+ '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
147157 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
148158 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
149159 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -161,6 +171,7 @@ export interface FileRouteTypes { · 11 unmodified lines
161171 | '/{-$orgSlug}/artifacts/$artifactId'
162172 | '/{-$orgSlug}/connect/$integrationSlug'
163173 | '/{-$orgSlug}/integrations/$namespace'
174+ | '/{-$orgSlug}/integrations/browse'
164175 | '/{-$orgSlug}/resume/$executionId'
165176 | '/{-$orgSlug}/toolkits/$toolkitSlug'
166177 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -176,6 +187,7 @@ export interface FileRouteTypes { · 9 unmodified lines
176187 | '/{-$orgSlug}/artifacts/$artifactId'
177188 | '/{-$orgSlug}/connect/$integrationSlug'
178189 | '/{-$orgSlug}/integrations/$namespace'
190+ | '/{-$orgSlug}/integrations/browse'
179191 | '/{-$orgSlug}/resume/$executionId'
180192 | '/{-$orgSlug}/toolkits/$toolkitSlug'
181193 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -191,6 +203,7 @@ export interface FileRouteTypes { · 9 unmodified lines
191203 | '/{-$orgSlug}/artifacts/$artifactId'
192204 | '/{-$orgSlug}/connect/$integrationSlug'
193205 | '/{-$orgSlug}/integrations/$namespace'
206+ | '/{-$orgSlug}/integrations/browse'
194207 | '/{-$orgSlug}/resume/$executionId'
195208 | '/{-$orgSlug}/toolkits/$toolkitSlug'
196209 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -206,6 +219,7 @@ export interface RootRouteChildren { · 9 unmodified lines
206219 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
207220 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
208221 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
222+ DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
209223 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
210224 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
211225 DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute
@@ -269,6 +283,13 @@ declare module '@tanstack/react-router' { · 57 unmodified lines
269283 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport
270284 parentRoute: typeof rootRouteImport
271285 }
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+ }
272293 '/{-$orgSlug}/integrations/$namespace': {
273294 id: '/{-$orgSlug}/integrations/$namespace'
274295 path: '/{-$orgSlug}/integrations/$namespace'
@@ -353,6 +374,8 @@ const rootRouteChildren: RootRouteChildren = { · 78 unmodified lines
353374 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute,
354375 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute:
355376 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute,
377+ DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute:
378+ DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute,
356379 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute:
357380 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute,
358381 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:
packages/app/src/web/shell.tsx+6−0
@@ -136,6 +136,12 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { · 135 unmodified lines
136136 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
137137 ].join(" ")}
138138 >
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. */}
139145 <IntegrationIconWithAccount
140146 icon={integrationPresetIconUrl(
141147 { 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
124124 readonly specOverrides?: readonly unknown[];
125125 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
126126 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;
127133 }
128134
129135 export type IntegrationPresetAuthentication =
@@ -199,6 +205,34 @@ export interface EditSheetSectionProps { · 69 unmodified lines
199205 readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void;
200206 }
201207
208+/** What a registry row already knows about a connect target — enough, for
209+ * some plugins, to register without showing the configuration screen. */
210+export interface IntegrationQuickAddInput {
211+ /** The connect target: MCP endpoint, OpenAPI spec URL, or GraphQL endpoint. */
212+ readonly url: string;
213+ /** Display name for the integration ("Stripe MCP", "Outlook Mail API"). */
214+ readonly name: string;
215+ /** Registry surface slug, used as the namespace seed when present. */
216+ readonly slug?: string;
217+ /** The registry PRODUCT's domain (notion.com) — the identity credential
218+ * guidance and favicons key on. Often differs from the connect URL's
219+ * host: specs live on code hosts. */
220+ readonly domain?: string;
221+ /** Registry-declared credential placement, e.g. "Authorization: {api_key}". */
222+ readonly authHeader?: string;
223+ /** Registry-declared credential kind ("none", "oauth", "api_key", …). */
224+ readonly authKind?: string;
225+ /** RFC 6902 patch the registry says to apply to the fetched spec. */
226+ readonly specOverrides?: readonly unknown[];
227+}
228+
229+export type IntegrationQuickAddResult =
230+ /** Registered; `slug` is the created integration's namespace. */
231+ | { readonly ok: true; readonly slug: string }
232+ /** Could not add headlessly — the host falls back to the configuration
233+ * screen, which renders the failure with full context. */
234+ | { readonly ok: false; readonly reason: string };
235+
202236 export interface IntegrationPlugin {
203237 /** Unique key matching the SDK plugin id (e.g. "openapi"). */
204238 readonly key: string;
@@ -213,6 +247,21 @@ export interface IntegrationPlugin { · 8 unmodified lines
213247 readonly initialUrl?: string;
214248 readonly initialPreset?: string;
215249 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;
216265 }>;
217266 /** Legacy full-page edit surface. No host renders this anymore — plugin
218267 * configuration lives in the integration Edit sheet via `editSheet`. */
@@ -245,6 +294,17 @@ export interface IntegrationPlugin { · 26 unmodified lines
245294 * Call from the host on intent (hover/focus) so the chunks land before the
246295 * user navigates into the add page. Idempotent. */
247296 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>;
248308 }
249309
250310 // ---------------------------------------------------------------------------
packages/core/sdk/src/plugin.ts+4−0
@@ -612,6 +612,10 @@ export interface IntegrationPreset { · 611 unmodified lines
612612 readonly specOverrides?: readonly unknown[];
613613 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
614614 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;
615619 readonly transport?: "remote" | "stdio";
616620 readonly command?: string;
617621 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+
3+A standalone reproduction of executor's **current** add-integration flow, from
4+"the CLI is connected" to "the integration has a working connection". No API, no
5+auth, no tenant — it runs off `src/fixtures.ts` and the public integrations.sh
6+endpoints.
7+
8+```
9+bun run --cwd packages/onboarding-demo dev # http://localhost:5199
10+```
11+
12+Every screen is addressable: `#integrations-empty`, `#connect-dialog`,
13+`#add-openapi`, `#detail-accounts`, `#add-account-credential`,
14+`#add-account-place`, `#oauth-stuck`, `#integrations-populated`. `[` and `]`
15+step through them. `src/flow.ts` records, per screen, the route and source
16+component it was reproduced from plus the first-run reactions against it.
17+
18+A floating Current / Reworked control in the bottom-right corner switches
19+between the two flows.
20+
21+## Reworked flow
22+
23+`#reworked` — the picker, `#reworked/installed`, `#reworked/custom`,
24+`#reworked/<domain>`, `#reworked/<domain>/auth`. Browse/Installed carries
25+navigation within the picker; the console sidebar stays as it is today. A deep link to a domain you have not added in this session
26+says so rather than rendering an empty screen — the prototype keeps no state
27+across reloads.
28+
29+Registry picks and custom URLs converge: both become an added integration with
30+one `default` account reading Needs auth. Only the detail header differs, naming
31+where the definition came from.
32+
33+## Fidelity
34+
35+- The curated preset list is imported from the real plugin modules
36+ (`@executor-js/plugin-openapi/presets`, `@executor-js/plugin-mcp/presets`),
37+ so it is the list the console shows, in the console's order.
38+- UI primitives and design tokens are imported from `@executor-js/react`, not
39+ copied — the reproduction uses the same buttons, dialogs, card stacks, tabs
40+ and tokens as the console.
41+- The catalog search calls the real `integrations.sh/api/search` with the same
42+ 250ms debounce and 2-character minimum.
43+- Screen structure and copy are transcribed from the source components named in
44+ the inspector.
45+
46+What is faked: the workspace's own data (integrations, connections, tools) and
47+every mutation. Adding an integration waits and moves on; nothing persists.
48+
49+`fixtures.ts` also exposes two integrations.sh endpoints the console does **not**
50+use today — `fetchCatalogDomains` (the whole ~3.4k-domain popularity-sorted
51+registry) and `fetchSurfaceCredentials` (per-domain credential label, the URL
52+that mints the key, and setup instructions). They are unused by the
53+reproduction and present as raw material for the rework.
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 @@
1+import { useCallback, useEffect, useMemo, useState } from "react";
2+import { cn } from "@executor-js/react/lib/utils";
3+import { Shell } from "./shell";
4+import { IntegrationsPage } from "./screens/integrations-page";
5+import { ConnectDialog } from "./screens/connect-dialog";
6+import { AddOpenApiPage } from "./screens/add-openapi-page";
7+import { IntegrationDetailPage } from "./screens/integration-detail";
8+import { AddAccountModal } from "./screens/add-account-modal";
9+import { flowSteps, type StepId } from "./flow";
10+import { stepState, type AccountModal, type ScreenView } from "./step-state";
11+import { NextFlow } from "./next-flow";
12+import {
13+ gmailAuthMethods,
14+ gmailIntegration,
15+ posthogAuthMethods,
16+ posthogIntegration,
17+ seededIntegrations,
18+ type DemoIntegration,
19+ type DemoPreset,
20+} from "./fixtures";
21+
22+const posthogPreset: DemoPreset = {
23+ id: "posthog",
24+ name: "PostHog",
25+ summary: "Product analytics, events, feature flags, and insights.",
26+ icon: "https://integrations.sh/logo/posthog.com",
27+ url: "https://raw.githubusercontent.com/PostHog/posthog/master/openapi/openapi.json",
28+ pluginKey: "openapi",
29+ pluginLabel: "OpenAPI",
30+};
31+
32+const stepFromHash = (): StepId => {
33+ const hash = globalThis.location?.hash.replace(/^#/, "") ?? "";
34+ return flowSteps.some((step) => step.id === hash) ? (hash as StepId) : "integrations-empty";
35+};
36+
37+function CurrentFlowApp() {
38+ // Each screen is addressable as `#<step-id>`, so a specific screen can be
39+ // linked to directly rather than clicked toward. `[` and `]` step through.
40+ const [stepId, setStepId] = useState<StepId>(stepFromHash);
41+ const [overrides, setOverrides] = useState<{
42+ readonly integrations?: readonly DemoIntegration[];
43+ readonly connectOpen?: boolean;
44+ readonly accountModal?: AccountModal;
45+ readonly view?: ScreenView;
46+ }>({});
47+
48+ const base = useMemo(() => stepState[stepId], [stepId]);
49+ const state = { ...base, ...overrides };
50+
51+ const goToStep = useCallback((id: StepId) => {
52+ setStepId(id);
53+ setOverrides({});
54+ if (globalThis.location) globalThis.location.hash = id;
55+ }, []);
56+
57+ useEffect(() => {
58+ const onHashChange = () => {
59+ setStepId(stepFromHash());
60+ setOverrides({});
61+ };
62+ globalThis.addEventListener("hashchange", onHashChange);
63+ return () => globalThis.removeEventListener("hashchange", onHashChange);
64+ }, []);
65+
66+ useEffect(() => {
67+ const onKey = (event: KeyboardEvent) => {
68+ if (event.key !== "[" && event.key !== "]") return;
69+ const index = flowSteps.findIndex((candidate) => candidate.id === stepId);
70+ const next = event.key === "]" ? index + 1 : index - 1;
71+ const target = flowSteps[next];
72+ if (target) goToStep(target.id);
73+ };
74+ globalThis.addEventListener("keydown", onKey);
75+ return () => globalThis.removeEventListener("keydown", onKey);
76+ }, [stepId, goToStep]);
77+
78+ const integrations = state.integrations ?? seededIntegrations;
79+
80+ return (
81+ <>
82+ <Shell integrations={integrations}>
83+ {state.view === "integrations" && (
84+ <IntegrationsPage
85+ integrations={integrations}
86+ onConnect={() => setOverrides((prev) => ({ ...prev, connectOpen: true }))}
87+ onOpenIntegration={() => setOverrides((prev) => ({ ...prev, view: "detail" }))}
88+ />
89+ )}
90+
91+ {state.view === "add" && (
92+ <AddOpenApiPage
93+ preset={posthogPreset}
94+ onCancel={() => goToStep("integrations-empty")}
95+ onComplete={() => goToStep("detail-accounts")}
96+ />
97+ )}
98+
99+ {state.view === "detail" && (
100+ <IntegrationDetailPage
101+ integration={
102+ state.accountModal === "oauth-stuck" ? gmailIntegration : posthogIntegration
103+ }
104+ onAddConnection={() =>
105+ setOverrides((prev) => ({ ...prev, accountModal: "credential" }))
106+ }
107+ />
108+ )}
109+ </Shell>
110+
111+ <ConnectDialog
112+ open={state.connectOpen === true}
113+ onOpenChange={(open) => setOverrides((prev) => ({ ...prev, connectOpen: open }))}
114+ onPickPreset={() => goToStep("add-openapi")}
115+ onPickPlugin={() => goToStep("add-openapi")}
116+ onPickCatalogEntry={() => goToStep("add-openapi")}
117+ />
118+
119+ {state.accountModal !== "none" && (
120+ <AddAccountModal
121+ // Remount per variant: a modal that stays mounted across a step
122+ // change keeps the previous step's wizard position.
123+ key={state.accountModal}
124+ open
125+ onOpenChange={(open) =>
126+ setOverrides((prev) => ({ ...prev, accountModal: open ? prev.accountModal : "none" }))
127+ }
128+ integrationName={state.accountModal === "oauth-stuck" ? "Gmail" : "PostHog"}
129+ methods={state.accountModal === "oauth-stuck" ? gmailAuthMethods : posthogAuthMethods}
130+ initialStep={state.accountModal === "place" ? "place" : "validate"}
131+ stuckConnecting={state.accountModal === "oauth-stuck"}
132+ onAdded={() => goToStep("integrations-populated")}
133+ />
134+ )}
135+ </>
136+ );
137+}
138+
139+// ---------------------------------------------------------------------------
140+// Mode switch
141+//
142+// Both flows run in the same console chrome, so the comparison is about the
143+// flow rather than the styling. The switch is a small floating control rather
144+// than a panel: it is scaffolding for looking at the prototype, and it should
145+// not take width away from the thing being looked at.
146+// ---------------------------------------------------------------------------
147+
148+type Mode = "current" | "next";
149+
150+function ModeSwitch(props: { readonly mode: Mode; readonly onChange: (mode: Mode) => void }) {
151+ const option = (mode: Mode, label: string) => (
152+ // oxlint-disable-next-line react/forbid-elements
153+ <button
154+ key={mode}
155+ type="button"
156+ onClick={() => props.onChange(mode)}
157+ className={cn(
158+ "rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
159+ props.mode === mode
160+ ? "bg-foreground text-background"
161+ : "text-muted-foreground hover:text-foreground",
162+ )}
163+ >
164+ {label}
165+ </button>
166+ );
167+ return (
168+ <div className="fixed bottom-4 right-4 z-50 flex items-center gap-0.5 rounded-full border border-border bg-card/95 p-1 shadow-lg backdrop-blur-sm">
169+ {option("current", "Current")}
170+ {option("next", "Reworked")}
171+ </div>
172+ );
173+}
174+
175+const modeFromHash = (): Mode => {
176+ const hash = globalThis.location?.hash ?? "";
177+ return hash.startsWith("#reworked") || hash.startsWith("#next") ? "next" : "current";
178+};
179+
180+export function App() {
181+ const [mode, setMode] = useState<Mode>(modeFromHash);
182+
183+ useEffect(() => {
184+ const onHashChange = () => setMode(modeFromHash());
185+ globalThis.addEventListener("hashchange", onHashChange);
186+ return () => globalThis.removeEventListener("hashchange", onHashChange);
187+ }, []);
188+
189+ const changeMode = (next: Mode) => {
190+ setMode(next);
191+ if (globalThis.location) {
192+ globalThis.location.hash = next === "next" ? "reworked" : "integrations-empty";
193+ }
194+ };
195+
196+ return (
197+ <div className="flex h-dvh w-full overflow-hidden bg-background text-foreground">
198+ {mode === "next" ? <NextFlow /> : <CurrentFlowApp />}
199+ <ModeSwitch mode={mode} onChange={changeMode} />
200+ </div>
201+ );
202+}
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+
16+import { fetchCatalogDomains, type CatalogDomain } from "./fixtures";
17+
18+export type Category =
19+ | "Featured"
20+ | "Inbox & calendar"
21+ | "Docs & files"
22+ | "Developer tools"
23+ | "Analytics"
24+ | "Payments"
25+ | "Support"
26+ | "Project management"
27+ | "Infrastructure"
28+ | "Everything else";
29+
30+export const CATEGORY_ORDER: readonly Category[] = [
31+ "Featured",
32+ "Inbox & calendar",
33+ "Docs & files",
34+ "Developer tools",
35+ "Analytics",
36+ "Payments",
37+ "Support",
38+ "Project management",
39+ "Infrastructure",
40+ "Everything else",
41+];
42+
43+const KEYWORDS: readonly (readonly [Category, readonly string[]])[] = [
44+ ["Inbox & calendar", ["email", "mail", "calendar", "inbox", "meeting", "scheduling"]],
45+ ["Docs & files", ["document", "file", "drive", "storage", "note", "wiki", "knowledge"]],
46+ ["Analytics", ["analytic", "metric", "tracking", "telemetry", "dashboard", "insight"]],
47+ ["Payments", ["payment", "invoice", "billing", "subscription", "checkout", "payout"]],
48+ ["Support", ["support", "ticket", "helpdesk", "customer", "chat"]],
49+ ["Project management", ["issue", "task", "project", "sprint", "roadmap", "backlog"]],
50+ [
51+ "Infrastructure",
52+ ["deploy", "server", "cluster", "database", "kubernetes", "cloud", "hosting", "dns"],
53+ ],
54+ ["Developer tools", ["repo", "commit", "pull request", "ci", "build", "code", "sdk", "api"]],
55+];
56+
57+/** Best-effort bucket from the description. Deliberately crude — see the file
58+ * header. Anything unmatched lands in "Everything else" rather than being
59+ * guessed at. */
60+const deriveCategory = (entry: CatalogDomain): Category => {
61+ const haystack = `${entry.domain} ${entry.description}`.toLowerCase();
62+ for (const [category, words] of KEYWORDS) {
63+ if (words.some((word) => haystack.includes(word))) return category;
64+ }
65+ return "Everything else";
66+};
67+
68+/** Domains the reference picker would call "Featured": the ones executor
69+ * already ships a curated preset for, which are also the ones most people
70+ * arrive wanting. */
71+const FEATURED_DOMAINS: readonly string[] = [
72+ "google.com",
73+ "github.com",
74+ "linear.app",
75+ "notion.com",
76+ "slack.com",
77+ "stripe.com",
78+ "figma.com",
79+ "posthog.com",
80+ "sentry.io",
81+ "vercel.com",
82+ "resend.com",
83+ "asana.com",
84+];
85+
86+export interface CatalogItem {
87+ readonly domain: string;
88+ readonly name: string;
89+ readonly description: string;
90+ readonly icon: string;
91+ readonly formats: readonly string[];
92+ readonly category: Category;
93+ readonly featured: boolean;
94+ readonly popularity: number;
95+}
96+
97+/** Title-case a domain into something that reads like a product name.
98+ * `linear.app` → `Linear`, `google.com` → `Google`. */
99+const displayName = (domain: string): string => {
100+ const stem = domain.split(".")[0] ?? domain;
101+ return stem.charAt(0).toUpperCase() + stem.slice(1);
102+};
103+
104+export const loadCatalog = async (): Promise<readonly CatalogItem[]> => {
105+ const domains = await fetchCatalogDomains();
106+ return domains.map((entry): CatalogItem => {
107+ const featured = FEATURED_DOMAINS.includes(entry.domain);
108+ return {
109+ domain: entry.domain,
110+ name: displayName(entry.domain),
111+ description: entry.description,
112+ icon: entry.icon,
113+ formats: Object.keys(entry.formats ?? {}),
114+ category: featured ? "Featured" : deriveCategory(entry),
115+ featured,
116+ popularity: entry.popularity,
117+ };
118+ });
119+};
120+
121+export const searchItems = (
122+ items: readonly CatalogItem[],
123+ query: string,
124+): readonly CatalogItem[] => {
125+ const q = query.trim().toLowerCase();
126+ if (q.length === 0) return items;
127+ return items.filter((item) =>
128+ `${item.domain} ${item.name} ${item.description}`.toLowerCase().includes(q),
129+ );
130+};
131+
132+// ---------------------------------------------------------------------------
133+// Added integrations
134+//
135+// A registry pick and a hand-pasted URL become the SAME kind of thing the
136+// moment they're added. Only `source` differs, and it only affects what the
137+// detail screen can say about where the definition came from — never how you
138+// authenticate it or where it lives.
139+// ---------------------------------------------------------------------------
140+
141+export interface AddedIntegration {
142+ readonly domain: string;
143+ readonly name: string;
144+ readonly description: string;
145+ readonly icon: string;
146+ readonly formats: readonly string[];
147+ readonly source: "registry" | "custom";
148+ /** Custom only: the endpoint or spec URL the user pasted. */
149+ readonly url?: string;
150+}
151+
152+export const fromCatalogItem = (item: CatalogItem): AddedIntegration => ({
153+ domain: item.domain,
154+ name: item.name,
155+ description: item.description,
156+ icon: item.icon,
157+ formats: item.formats,
158+ source: "registry",
159+});
160+
161+/** integrations.sh serves a favicon for any domain, registry entry or not, so
162+ * a custom integration still gets a real logo. */
163+export const logoFor = (domain: string): string => `https://integrations.sh/logo/${domain}`;
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+
11+import { openApiPresets, type OpenApiPreset } from "@executor-js/plugin-openapi/presets";
12+import { mcpPresets, type McpPreset } from "@executor-js/plugin-mcp/presets";
13+
14+export type PluginKey = "openapi" | "mcp" | "graphql" | "google";
15+
16+export interface DemoPreset {
17+ readonly id: string;
18+ readonly name: string;
19+ readonly summary: string;
20+ readonly icon?: string;
21+ readonly url?: string;
22+ readonly pluginKey: PluginKey;
23+ readonly pluginLabel: string;
24+}
25+
26+const fromOpenApi = (preset: OpenApiPreset): DemoPreset => ({
27+ id: preset.id,
28+ name: preset.name,
29+ summary: preset.summary,
30+ ...(preset.icon ? { icon: preset.icon } : {}),
31+ ...(preset.url ? { url: preset.url } : {}),
32+ pluginKey: "openapi",
33+ pluginLabel: "OpenAPI",
34+});
35+
36+const fromMcp = (preset: McpPreset): DemoPreset => ({
37+ id: preset.id,
38+ name: preset.name,
39+ summary: preset.summary,
40+ ...(preset.icon ? { icon: preset.icon } : {}),
41+ ...("url" in preset && preset.url ? { url: preset.url } : {}),
42+ pluginKey: "mcp",
43+ pluginLabel: "MCP",
44+});
45+
46+/** The console's "Popular integrations" list, in the console's own order:
47+ * every loaded plugin's presets, concatenated plugin by plugin. */
48+export const curatedPresets: readonly DemoPreset[] = [
49+ ...openApiPresets.map(fromOpenApi),
50+ ...mcpPresets.map(fromMcp),
51+];
52+
53+/** The plugin chips under "Or add manually". */
54+export const integrationPlugins: readonly { readonly key: PluginKey; readonly label: string }[] = [
55+ { key: "openapi", label: "OpenAPI" },
56+ { key: "mcp", label: "MCP" },
57+ { key: "graphql", label: "GraphQL" },
58+ { key: "google", label: "Google" },
59+];
60+
61+// ---------------------------------------------------------------------------
62+// Workspace state — what a brand-new tenant has, which is nothing
63+// ---------------------------------------------------------------------------
64+
65+export interface DemoIntegration {
66+ readonly slug: string;
67+ readonly name: string;
68+ readonly kind: PluginKey;
69+ readonly icon?: string;
70+ readonly toolCount: number;
71+}
72+
73+export const seededIntegrations: readonly DemoIntegration[] = [];
74+
75+/** What the workspace looks like after the user adds PostHog — the state Theo
76+ * reached before hunting for the key field. */
77+export const posthogIntegration: DemoIntegration = {
78+ slug: "posthog",
79+ name: "PostHog",
80+ kind: "openapi",
81+ icon: "https://integrations.sh/logo/posthog.com",
82+ toolCount: 0,
83+};
84+
85+/** The OAuth-only integration, for the step where the popup dies. */
86+export const gmailIntegration: DemoIntegration = {
87+ slug: "gmail",
88+ name: "Gmail",
89+ kind: "google",
90+ icon: "https://integrations.sh/logo/google.com",
91+ toolCount: 0,
92+};
93+
94+// ---------------------------------------------------------------------------
95+// Auth methods for the add-connection modal
96+//
97+// Shape mirrors `AuthMethod` in packages/react/src/lib/auth-placements.tsx.
98+// The labels are the ones a spec-derived integration actually produces: an
99+// OpenAPI spec whose security schemes carry no human name yields ordinal
100+// method labels, which is the "Method 1" tab.
101+// ---------------------------------------------------------------------------
102+
103+export interface DemoPlacement {
104+ readonly carrier: "header" | "query" | "env";
105+ readonly name: string;
106+ readonly prefix: string;
107+}
108+
109+export interface DemoAuthMethod {
110+ readonly id: string;
111+ readonly label: string;
112+ readonly kind: "apikey" | "oauth" | "none";
113+ readonly placements: readonly DemoPlacement[];
114+}
115+
116+export const posthogAuthMethods: readonly DemoAuthMethod[] = [
117+ {
118+ id: "method-1",
119+ label: "Method 1",
120+ kind: "apikey",
121+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
122+ },
123+ {
124+ id: "method-2",
125+ label: "Method 2",
126+ kind: "apikey",
127+ placements: [{ carrier: "query", name: "personal_api_key", prefix: "" }],
128+ },
129+ { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] },
130+];
131+
132+export const gmailAuthMethods: readonly DemoAuthMethod[] = [
133+ { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] },
134+];
135+
136+// ---------------------------------------------------------------------------
137+// integrations.sh — the live public catalog
138+// ---------------------------------------------------------------------------
139+
140+export const INTEGRATIONS_SH_ORIGIN = "https://integrations.sh";
141+
142+export const CONNECTABLE_KINDS = ["mcp", "openapi", "graphql"] as const;
143+export type CatalogKind = (typeof CONNECTABLE_KINDS)[number];
144+
145+export interface CatalogEntry {
146+ readonly domain: string;
147+ readonly description: string;
148+ readonly kinds: readonly CatalogKind[];
149+}
150+
151+export const catalogLogoUrl = (domain: string, size: number): string =>
152+ `${INTEGRATIONS_SH_ORIGIN}/logo/${domain}?sz=${size * 2}`;
153+
154+const isConnectableKind = (kind: string): kind is CatalogKind =>
155+ (CONNECTABLE_KINDS as readonly string[]).includes(kind);
156+
157+/** `/api/search` — the same endpoint the console's connect dialog calls, and
158+ * the same 250ms-debounced, ≥2-character contract. */
159+export const searchCatalog = async (
160+ query: string,
161+ limit = 10,
162+): Promise<readonly CatalogEntry[]> => {
163+ const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN);
164+ url.searchParams.set("q", query);
165+ url.searchParams.set("limit", String(limit));
166+ const response = await fetch(url);
167+ if (!response.ok) return [];
168+ const payload = (await response.json()) as {
169+ readonly results?: readonly {
170+ readonly domain: string;
171+ readonly description: string;
172+ readonly kinds: readonly string[];
173+ }[];
174+ };
175+ return (payload.results ?? [])
176+ .map((entry) => ({
177+ domain: entry.domain,
178+ description: entry.description,
179+ kinds: entry.kinds.filter(isConnectableKind),
180+ }))
181+ .filter((entry) => entry.kinds.length > 0);
182+};
183+
184+export interface CatalogDomain {
185+ readonly domain: string;
186+ readonly icon: string;
187+ readonly description: string;
188+ readonly formats: Readonly<Record<string, number>>;
189+ readonly popularity: number;
190+}
191+
192+/** `/api/domains.json` — the whole popularity-sorted registry (~3.4k domains).
193+ * The console never calls this; it is here because it is the list the product
194+ * is missing, and the rework will want it. */
195+export const fetchCatalogDomains = async (): Promise<readonly CatalogDomain[]> => {
196+ const response = await fetch(`${INTEGRATIONS_SH_ORIGIN}/api/domains.json`);
197+ if (!response.ok) return [];
198+ const payload = (await response.json()) as { readonly data?: readonly CatalogDomain[] };
199+ return payload.data ?? [];
200+};
201+
202+export interface SurfaceCredential {
203+ readonly id: string;
204+ readonly type: string;
205+ readonly label: string;
206+ readonly generateUrl?: string;
207+ readonly setup?: string;
208+}
209+
210+/** `/api/<domain>/surface` — the per-domain document. The console reads only
211+ * `surfaces[].url` out of this; the `credentials` block (which key, where to
212+ * mint it, how) is fetched and thrown away. */
213+export const fetchSurfaceCredentials = async (
214+ domain: string,
215+): Promise<readonly SurfaceCredential[]> => {
216+ const response = await fetch(
217+ `${INTEGRATIONS_SH_ORIGIN}/api/${encodeURIComponent(domain)}/surface`,
218+ );
219+ if (!response.ok) return [];
220+ const payload = (await response.json()) as {
221+ readonly credentials?: Readonly<
222+ Record<
223+ string,
224+ {
225+ readonly type?: string;
226+ readonly label?: string;
227+ readonly generateUrl?: string;
228+ readonly setup?: string;
229+ }
230+ >
231+ >;
232+ };
233+ return Object.entries(payload.credentials ?? {}).map(([id, value]) => ({
234+ id,
235+ type: value.type ?? "unknown",
236+ label: value.label ?? id,
237+ ...(value.generateUrl ? { generateUrl: value.generateUrl } : {}),
238+ ...(value.setup ? { setup: value.setup } : {}),
239+ }));
240+};
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+
9+export type StepId =
10+ | "integrations-empty"
11+ | "connect-dialog"
12+ | "add-openapi"
13+ | "detail-accounts"
14+ | "add-account-credential"
15+ | "add-account-place"
16+ | "oauth-stuck"
17+ | "integrations-populated";
18+
19+export interface FlowStep {
20+ readonly id: StepId;
21+ readonly label: string;
22+ /** Where this screen lives in the real console. */
23+ readonly route: string;
24+ /** The component this reproduction is taken from. */
25+ readonly source: string;
26+ /** Verbatim first-run reactions to this screen. */
27+ readonly reactions: readonly string[];
28+}
29+
30+export const flowSteps: readonly FlowStep[] = [
31+ {
32+ id: "integrations-empty",
33+ label: "Integrations (empty)",
34+ route: "/",
35+ source: "packages/react/src/pages/integrations.tsx",
36+ reactions: [
37+ "Adding to clis was easy. Not clear what to do next.",
38+ "Next is this, not a great screen to find integrations from. No text search? I just wanted to add email",
39+ ],
40+ },
41+ {
42+ id: "connect-dialog",
43+ label: "Connect dialog",
44+ route: "/ (dialog)",
45+ source: "packages/react/src/pages/integrations.tsx › ConnectDialog",
46+ reactions: [
47+ "I'd love a proper like picker grid and search",
48+ "I want to add multiple to set up for experimentation. I clicked the first one I want (posthog) and it brought me here",
49+ "Annoying because now I have to sit and wait for it to do whatever and then add, and I could be spending this time finding other integrations I want",
50+ ],
51+ },
52+ {
53+ id: "add-openapi",
54+ label: "Add integration form",
55+ route: "/integrations/add/openapi",
56+ source: "packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx",
57+ reactions: [
58+ "This view is a bit of a mess. There's no clear hierarchy on what exactly I'm supposed to be doing, what matters and doesn't, and what button I should hit. There's the big add integration at the bottom and the weird treatment around it. Just very unclear what the step is",
59+ "Hit “add” and now another loading screen, well spinning wheel button",
60+ ],
61+ },
62+ {
63+ id: "detail-accounts",
64+ label: "Integration detail · Accounts",
65+ route: "/integrations/posthog?tab=accounts",
66+ source: "packages/react/src/pages/integration-detail.tsx",
67+ reactions: [
68+ "wtf is this?? I just added it why do I have to add it again??",
69+ "The hierarchy of the integrations and connections is not something I should have to understand or care about as I initially setup",
70+ "I increasingly hate this view. The more that I look at it, there are just so many things demanding attention and none of them are the thing I actually want to do",
71+ ],
72+ },
73+ {
74+ id: "add-account-credential",
75+ label: "Add connection · credential",
76+ route: "/integrations/posthog?tab=accounts&addAccount=1",
77+ source: "packages/react/src/components/add-account-modal.tsx",
78+ reactions: [
79+ "Where the fk do I paste the key?? The Method 1 thing is locked",
80+ "I'm not scared of doing anything because I already copied the API key and it's on my clipboard but I can't paste it yet. I just have to not use my computer because I don't have clipboard history management and never will",
81+ "I'm even scared to send a screenshot because I'm going to lose the key. I'm just going to have to recreate it. I think I'll throw it in a note.",
82+ "Okay so what type of key do I need? Obviously doing this for every integration will be annoying but I'm sure you can get some agent to go do it.",
83+ ],
84+ },
85+ {
86+ id: "add-account-place",
87+ label: "Add connection · placement + owner",
88+ route: "/integrations/posthog?tab=accounts&addAccount=1",
89+ source: "packages/react/src/components/add-account-modal.tsx",
90+ reactions: ["How many times does the word “personal” appear on this page? lol"],
91+ },
92+ {
93+ id: "oauth-stuck",
94+ label: "OAuth · stuck connecting",
95+ route: "/integrations/gmail?tab=accounts&addAccount=1",
96+ source: "packages/react/src/plugins/oauth-sign-in.tsx",
97+ reactions: [
98+ "Closed the broken auth page and now app is stuck in “connecting...”",
99+ "On business Gmail, allowed me to click an “Advanced options” (“I trust the developer”) thing, but normal personal accounts, no",
100+ ],
101+ },
102+ {
103+ id: "integrations-populated",
104+ label: "Integrations (populated)",
105+ route: "/",
106+ source: "packages/react/src/pages/integrations.tsx",
107+ reactions: ["This is filling me with rage for some reason"],
108+ },
109+];
110+
111+export const stepIndex = (id: StepId): number => flowSteps.findIndex((step) => step.id === id);
packages/onboarding-demo/src/main.tsx+12−0
@@ -0,0 +1,12 @@
1+import { StrictMode } from "react";
2+import { createRoot } from "react-dom/client";
3+import { App } from "./app";
4+import "./styles.css";
5+
6+const root = document.getElementById("root");
7+if (root)
8+ createRoot(root).render(
9+ <StrictMode>
10+ <App />
11+ </StrictMode>,
12+ );
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+
13+import { useCallback, useEffect, useState } from "react";
14+import { Button } from "@executor-js/react/components/button";
15+import { cn } from "@executor-js/react/lib/utils";
16+import { Shell } from "./shell";
17+import { BrowsePage } from "./screens/next/browse";
18+import {
19+ InstalledList,
20+ NextIntegrationDetail,
21+ type DemoAccount,
22+} from "./screens/next/integration-detail";
23+import { AuthenticateDialog } from "./screens/next/authenticate";
24+import { AddCustomDialog } from "./screens/next/custom";
25+import { fromCatalogItem, type AddedIntegration, type CatalogItem } from "./catalog";
26+import type { DemoIntegration } from "./fixtures";
27+
28+interface Added {
29+ readonly item: AddedIntegration;
30+ readonly accounts: readonly DemoAccount[];
31+}
32+
33+// ---------------------------------------------------------------------------
34+// Routing
35+// ---------------------------------------------------------------------------
36+
37+export type NextRoute =
38+ | { readonly kind: "browse" }
39+ | { readonly kind: "installed" }
40+ | { readonly kind: "custom" }
41+ | { readonly kind: "detail"; readonly domain: string }
42+ | { readonly kind: "auth"; readonly domain: string };
43+
44+const parseRoute = (hash: string): NextRoute => {
45+ // `#reworked/...` is canonical; `#next/...` is kept working because it was
46+ // the first spelling and may already be pasted somewhere.
47+ const path = hash.replace(/^#/, "").replace(/^(reworked|next)\/?/, "");
48+ const segments = path.split("/").filter((segment) => segment.length > 0);
49+ if (segments.length === 0) return { kind: "browse" };
50+ const [first, second] = segments;
51+ if (first === "installed") return { kind: "installed" };
52+ if (first === "custom") return { kind: "custom" };
53+ if (first && second === "auth") return { kind: "auth", domain: first };
54+ if (first) return { kind: "detail", domain: first };
55+ return { kind: "browse" };
56+};
57+
58+const routeToHash = (route: NextRoute): string => {
59+ if (route.kind === "browse") return "#reworked";
60+ if (route.kind === "installed") return "#reworked/installed";
61+ if (route.kind === "custom") return "#reworked/custom";
62+ if (route.kind === "auth") return `#reworked/${route.domain}/auth`;
63+ return `#reworked/${route.domain}`;
64+};
65+
66+// ---------------------------------------------------------------------------
67+
68+const toolsFor = (accounts: readonly DemoAccount[]): number =>
69+ accounts.some((account) => account.status === "connected") ? 24 : 0;
70+
71+/** What the console sidebar lists: the same added integrations, in the shape
72+ * the shell already renders. */
73+const asSidebarIntegration = (added: Added): DemoIntegration => ({
74+ slug: added.item.domain,
75+ name: added.item.name,
76+ kind: "openapi",
77+ icon: added.item.icon,
78+ toolCount: toolsFor(added.accounts),
79+});
80+
81+export function NextFlow() {
82+ const [added, setAdded] = useState<readonly Added[]>([]);
83+ const [route, setRoute] = useState<NextRoute>(() => parseRoute(globalThis.location?.hash ?? ""));
84+
85+ useEffect(() => {
86+ const onHashChange = () => setRoute(parseRoute(globalThis.location?.hash ?? ""));
87+ globalThis.addEventListener("hashchange", onHashChange);
88+ return () => globalThis.removeEventListener("hashchange", onHashChange);
89+ }, []);
90+
91+ const go = useCallback((next: NextRoute) => {
92+ setRoute(next);
93+ if (globalThis.location) globalThis.location.hash = routeToHash(next);
94+ }, []);
95+
96+ const addIntegration = useCallback((item: AddedIntegration) => {
97+ setAdded((previous) =>
98+ previous.some((entry) => entry.item.domain === item.domain)
99+ ? previous
100+ : [
101+ ...previous,
102+ // An add always leaves exactly one account behind, already named
103+ // and in the one state that matters.
104+ { item, accounts: [{ label: "default", status: "needs-auth" as const }] },
105+ ],
106+ );
107+ }, []);
108+
109+ const addFromCatalog = useCallback(
110+ (item: CatalogItem) => addIntegration(fromCatalogItem(item)),
111+ [addIntegration],
112+ );
113+
114+ const markConnected = useCallback((domain: string, accountLabel: string, identity: string) => {
115+ setAdded((previous) =>
116+ previous.map((entry) =>
117+ entry.item.domain === domain
118+ ? {
119+ ...entry,
120+ accounts: entry.accounts.map((account) =>
121+ account.label === accountLabel
122+ ? { ...account, status: "connected" as const, identity }
123+ : account,
124+ ),
125+ }
126+ : entry,
127+ ),
128+ );
129+ }, []);
130+
131+ const activeDomain = route.kind === "detail" || route.kind === "auth" ? route.domain : null;
132+ const open = added.find((entry) => entry.item.domain === activeDomain) ?? null;
133+ const pendingCount = added.filter((entry) =>
134+ entry.accounts.some((account) => account.status === "needs-auth"),
135+ ).length;
136+
137+ // A deep link can name a domain that has not been added in this session.
138+ // Say so rather than rendering an empty detail screen.
139+ const missing = activeDomain !== null && open === null;
140+
141+ const showingInstalled = route.kind === "installed";
142+ const showingBrowse = route.kind === "browse" || route.kind === "custom";
143+
144+ return (
145+ <Shell integrations={added.map(asSidebarIntegration)}>
146+ <div className="min-h-0 flex-1 overflow-y-auto">
147+ <div className="mx-auto max-w-4xl px-8 py-10">
148+ {open && !missing ? (
149+ <NextIntegrationDetail
150+ item={open.item}
151+ accounts={open.accounts}
152+ toolCount={toolsFor(open.accounts)}
153+ onBack={() => go({ kind: "installed" })}
154+ onAuthenticate={() => go({ kind: "auth", domain: open.item.domain })}
155+ onAddAccount={() =>
156+ setAdded((previous) =>
157+ previous.map((entry) =>
158+ entry.item.domain === open.item.domain
159+ ? {
160+ ...entry,
161+ accounts: [
162+ ...entry.accounts,
163+ {
164+ label: `account ${entry.accounts.length + 1}`,
165+ status: "needs-auth" as const,
166+ },
167+ ],
168+ }
169+ : entry,
170+ ),
171+ )
172+ }
173+ onRemove={() => {
174+ setAdded((previous) =>
175+ previous.filter((entry) => entry.item.domain !== open.item.domain),
176+ );
177+ go({ kind: "installed" });
178+ }}
179+ />
180+ ) : missing ? (
181+ <div className="rounded-lg border border-dashed border-border py-16 text-center">
182+ <p className="text-sm font-medium text-foreground">
183+ {activeDomain} isn&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+
12+import { useState } from "react";
13+import { EyeIcon, EyeOffIcon, PlusIcon } from "lucide-react";
14+import { Button } from "@executor-js/react/components/button";
15+import { Input } from "@executor-js/react/components/input";
16+import { Label } from "@executor-js/react/components/label";
17+import {
18+ Dialog,
19+ DialogContent,
20+ DialogDescription,
21+ DialogFooter,
22+ DialogHeader,
23+ DialogTitle,
24+} from "@executor-js/react/components/dialog";
25+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs";
26+import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";
27+import { cn } from "@executor-js/react/lib/utils";
28+import type { DemoAuthMethod } from "../fixtures";
29+
30+function StepHeader(props: { readonly label: string; readonly hint?: string }) {
31+ return (
32+ <div className="flex flex-col gap-1">
33+ <Label className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
34+ {props.label}
35+ </Label>
36+ {props.hint ? <span className="text-xs text-muted-foreground/70">{props.hint}</span> : null}
37+ </div>
38+ );
39+}
40+
41+function PlacementLine(props: { readonly placement: DemoAuthMethod["placements"][number] }) {
42+ const { placement } = props;
43+ const lead =
44+ placement.carrier === "header"
45+ ? `${placement.name || "Authorization"}: `
46+ : placement.carrier === "env"
47+ ? `${placement.name || "TOKEN"}=`
48+ : `?${placement.name || "api_key"}=`;
49+ return (
50+ <span className="wrap-anywhere whitespace-pre-wrap font-mono text-xs text-muted-foreground">
51+ {lead}
52+ {placement.prefix ? (
53+ <span className="text-muted-foreground/60">{placement.prefix}</span>
54+ ) : null}
55+ <span className="tracking-widest text-primary">••••••</span>
56+ </span>
57+ );
58+}
59+
60+export function AddAccountModal(props: {
61+ readonly open: boolean;
62+ readonly onOpenChange: (open: boolean) => void;
63+ readonly integrationName: string;
64+ readonly methods: readonly DemoAuthMethod[];
65+ /** Start on the wizard's second step, which is the screen the owner picker
66+ * and its repeated "Personal" live on. */
67+ readonly initialStep?: "validate" | "place";
68+ /** Reproduce the post-OAuth stuck state: the popup was closed, and the
69+ * button never leaves "Connecting…". */
70+ readonly stuckConnecting?: boolean;
71+ readonly onAdded: () => void;
72+}) {
73+ const [methodId, setMethodId] = useState(props.methods[0]?.id ?? "");
74+ const [wizardStep, setWizardStep] = useState<"validate" | "place">(
75+ props.initialStep ?? "validate",
76+ );
77+ const [secret, setSecret] = useState("");
78+ const [revealed, setRevealed] = useState(false);
79+ const [label, setLabel] = useState("");
80+ const [owner, setOwner] = useState<"user" | "org">("user");
81+
82+ const method = props.methods.find((candidate) => candidate.id === methodId) ?? props.methods[0];
83+ const isOAuth = method?.kind === "oauth";
84+ const wizardActive = !isOAuth;
85+ const showValidateStep = !wizardActive || wizardStep === "validate";
86+ const showPlaceStep = !wizardActive || wizardStep === "place";
87+
88+ const ownerWord = owner === "user" ? "Personal" : "Workspace";
89+ const derivedName = `${ownerWord} ${props.integrationName}`;
90+ const callableName = (label.trim() || derivedName).toLowerCase().replace(/[^a-z0-9]+/g, "_");
91+
92+ const singlePlacement = method?.placements[0];
93+ const affix =
94+ singlePlacement && singlePlacement.carrier === "header"
95+ ? `${singlePlacement.name}: ${singlePlacement.prefix}`
96+ : null;
97+
98+ return (
99+ <Dialog open={props.open} onOpenChange={props.onOpenChange} modal={false}>
100+ <DialogContent forceOverlay className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
101+ <DialogHeader>
102+ <DialogTitle>
103+ Add connection · {props.integrationName}
104+ {wizardActive ? (
105+ <span className="ml-2 font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
106+ Step {wizardStep === "validate" ? 1 : 2} of 2
107+ </span>
108+ ) : null}
109+ </DialogTitle>
110+ <DialogDescription>
111+ A connection is a saved way to use this integration, owned by you or the workspace.
112+ </DialogDescription>
113+ </DialogHeader>
114+
115+ <div className="flex w-full min-w-0 flex-col gap-5">
116+ {showValidateStep && (
117+ <Tabs
118+ value={methodId}
119+ onValueChange={(next: string) => {
120+ setMethodId(next);
121+ setWizardStep("validate");
122+ }}
123+ className="w-full min-w-0 max-w-full gap-0"
124+ >
125+ <TabsList className="flex h-10 w-full min-w-0 max-w-full justify-start overflow-x-auto overflow-y-hidden rounded-b-none rounded-t-md border border-b-0 border-border/60 bg-muted/30 p-1 [scrollbar-width:thin]">
126+ <div className="flex w-max shrink-0 items-stretch gap-1">
127+ {props.methods.map((candidate) => (
128+ <div
129+ key={candidate.id}
130+ className="group/method-tab relative flex h-8 shrink-0 items-stretch"
131+ >
132+ <TabsTrigger
133+ value={candidate.id}
134+ className="h-full max-w-64 shrink-0 justify-start px-3 text-sm font-medium"
135+ >
136+ <span className="truncate">{candidate.label}</span>
137+ </TabsTrigger>
138+ </div>
139+ ))}
140+ <Button
141+ type="button"
142+ variant="ghost"
143+ size="icon-sm"
144+ aria-label="Add authentication method"
145+ className="h-8 shrink-0 rounded-md border border-transparent bg-transparent px-3 text-foreground/60 hover:bg-background/60"
146+ >
147+ <PlusIcon className="size-4" />
148+ </Button>
149+ </div>
150+ </TabsList>
151+
152+ <TabsContent
153+ value={methodId}
154+ className={cn(
155+ "mt-0 min-w-0 space-y-5",
156+ "rounded-b-md rounded-t-none border border-border/60 bg-muted/15 p-4",
157+ )}
158+ >
159+ {method && !isOAuth && method.placements.length > 0 && !affix ? (
160+ <div className="flex flex-wrap gap-x-3.5 gap-y-1">
161+ {method.placements.map((placement, index) => (
162+ <PlacementLine key={index} placement={placement} />
163+ ))}
164+ </div>
165+ ) : null}
166+
167+ <div className="space-y-2">
168+ <StepHeader label={isOAuth ? "OAuth app" : "Credential"} />
169+
170+ {isOAuth ? (
171+ <div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
172+ <p className="text-sm font-medium">No app for {props.integrationName} yet</p>
173+ <p className="text-xs text-muted-foreground">
174+ None of your registered apps target this integration&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+
11+import { useState } from "react";
12+import { LockIcon, XIcon, ChevronDownIcon } from "lucide-react";
13+import { Button } from "@executor-js/react/components/button";
14+import { Input } from "@executor-js/react/components/input";
15+import { Label } from "@executor-js/react/components/label";
16+import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";
17+import type { DemoPreset } from "../fixtures";
18+
19+function PlacementLine(props: {
20+ readonly carrier: "header" | "query";
21+ readonly name: string;
22+ readonly prefix: string;
23+}) {
24+ const lead = props.carrier === "header" ? `${props.name}: ` : `?${props.name}=`;
25+ return (
26+ <span className="wrap-anywhere whitespace-pre-wrap font-mono text-xs text-muted-foreground">
27+ {lead}
28+ {props.prefix ? <span className="text-muted-foreground/60">{props.prefix}</span> : null}
29+ <span className="tracking-widest text-primary">••••••</span>
30+ </span>
31+ );
32+}
33+
34+function FieldLabel(props: { readonly children: React.ReactNode }) {
35+ return (
36+ <Label className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
37+ {props.children}
38+ </Label>
39+ );
40+}
41+
42+export function AddOpenApiPage(props: {
43+ readonly preset: DemoPreset | null;
44+ readonly onCancel: () => void;
45+ readonly onComplete: () => void;
46+}) {
47+ const [adding, setAdding] = useState(false);
48+ const [overridesOpen, setOverridesOpen] = useState(false);
49+ const [healthCheckOpen, setHealthCheckOpen] = useState(false);
50+ const name = props.preset?.name ?? "API";
51+ const slug = props.preset?.id ?? "api";
52+
53+ return (
54+ <div className="relative min-h-0 flex-1 overflow-y-auto">
55+ <div className="mx-auto flex min-h-full max-w-4xl flex-col gap-6 px-6 py-10 lg:px-10 lg:py-14">
56+ <div>
57+ <h1 className="text-xl font-semibold text-foreground">Add OpenAPI integration</h1>
58+ </div>
59+
60+ {/* Details — OpenApiIntegrationDetailsFields */}
61+ <CardStack>
62+ <CardStackContent className="border-t-0">
63+ <div className="flex items-start gap-3 px-4 py-4">
64+ {props.preset?.icon ? (
65+ <img src={props.preset.icon} alt="" className="size-8 shrink-0 object-contain" />
66+ ) : (
67+ <span className="size-8 shrink-0 rounded-md bg-muted" />
68+ )}
69+ <div className="min-w-0">
70+ <p className="text-sm font-medium text-foreground">{name}</p>
71+ <p className="text-xs text-muted-foreground">1.0.0 · 214 operations · 31 tags</p>
72+ </div>
73+ </div>
74+ <div className="space-y-4 px-4 pb-4">
75+ <div className="space-y-2">
76+ <FieldLabel>Integration ID</FieldLabel>
77+ <Input defaultValue={slug} className="font-mono text-sm" />
78+ </div>
79+ <div className="space-y-2">
80+ <FieldLabel>Display name</FieldLabel>
81+ <Input defaultValue={name} />
82+ </div>
83+ <div className="space-y-2">
84+ <FieldLabel>Description</FieldLabel>
85+ <Input defaultValue={props.preset?.summary ?? ""} />
86+ </div>
87+ <div className="space-y-2">
88+ <FieldLabel>Base URL override (optional)</FieldLabel>
89+ <Input placeholder="https://api.example.com" className="font-mono text-sm" />
90+ <p className="text-[11px] text-muted-foreground">
91+ Overrides the spec&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+
9+import { useEffect, useMemo, useRef, useState } from "react";
10+import { Button } from "@executor-js/react/components/button";
11+import { Input } from "@executor-js/react/components/input";
12+import { Badge } from "@executor-js/react/components/badge";
13+import { Skeleton } from "@executor-js/react/components/skeleton";
14+import {
15+ Dialog,
16+ DialogContent,
17+ DialogDescription,
18+ DialogHeader,
19+ DialogTitle,
20+} from "@executor-js/react/components/dialog";
21+import {
22+ CardStack,
23+ CardStackContent,
24+ CardStackEntry,
25+ CardStackEntryActions,
26+ CardStackEntryContent,
27+ CardStackEntryDescription,
28+ CardStackEntryTitle,
29+} from "@executor-js/react/components/card-stack";
30+import {
31+ catalogLogoUrl,
32+ curatedPresets,
33+ integrationPlugins,
34+ searchCatalog,
35+ type CatalogEntry,
36+ type DemoPreset,
37+ type PluginKey,
38+} from "../fixtures";
39+
40+const CATALOG_KIND_LABEL: Record<string, string> = {
41+ mcp: "MCP",
42+ openapi: "OpenAPI",
43+ graphql: "GraphQL",
44+};
45+
46+const looksLikeUrl = (raw: string): boolean => {
47+ const v = raw.trim();
48+ if (v.length === 0) return false;
49+ if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true;
50+ if (v.includes("/")) return true;
51+ if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true;
52+ return false;
53+};
54+
55+const SEARCH_DEBOUNCE_MS = 250;
56+const MIN_QUERY_LENGTH = 2;
57+
58+function useCatalogSearch(rawQuery: string) {
59+ const query = rawQuery.trim().toLowerCase();
60+ const [state, setState] = useState<{
61+ readonly entries: readonly CatalogEntry[];
62+ readonly loading: boolean;
63+ }>({ entries: [], loading: false });
64+ const generation = useRef(0);
65+
66+ useEffect(() => {
67+ const requestId = ++generation.current;
68+ if (query.length < MIN_QUERY_LENGTH) {
69+ setState({ entries: [], loading: false });
70+ return;
71+ }
72+ setState((previous) => ({ ...previous, loading: true }));
73+ const timer = setTimeout(() => {
74+ void searchCatalog(query).then((entries) => {
75+ if (generation.current !== requestId) return;
76+ setState({ entries, loading: false });
77+ });
78+ }, SEARCH_DEBOUNCE_MS);
79+ return () => clearTimeout(timer);
80+ }, [query]);
81+
82+ return state;
83+}
84+
85+export function ConnectDialog(props: {
86+ readonly open: boolean;
87+ readonly onOpenChange: (open: boolean) => void;
88+ readonly onPickPreset: (preset: DemoPreset) => void;
89+ readonly onPickPlugin: (pluginKey: PluginKey) => void;
90+ readonly onPickCatalogEntry: (entry: CatalogEntry) => void;
91+}) {
92+ const [query, setQuery] = useState("");
93+ const isUrl = looksLikeUrl(query);
94+ const presetSearch = isUrl ? "" : query;
95+
96+ const filtered = useMemo(() => {
97+ const q = presetSearch.trim().toLowerCase();
98+ if (q.length === 0) return curatedPresets;
99+ return curatedPresets.filter(({ name, summary, pluginLabel }) =>
100+ `${name} ${summary} ${pluginLabel}`.toLowerCase().includes(q),
101+ );
102+ }, [presetSearch]);
103+
104+ const catalog = useCatalogSearch(presetSearch);
105+ const showCatalogSection =
106+ presetSearch.trim().length > 0 && (catalog.entries.length > 0 || catalog.loading);
107+
108+ const close = () => {
109+ setQuery("");
110+ props.onOpenChange(false);
111+ };
112+
113+ return (
114+ <Dialog
115+ open={props.open}
116+ onOpenChange={(open: boolean) => {
117+ if (!open) close();
118+ else props.onOpenChange(open);
119+ }}
120+ >
121+ <DialogContent className="sm:max-w-[560px]">
122+ <DialogHeader>
123+ <DialogTitle>Connect an integration</DialogTitle>
124+ <DialogDescription>
125+ Search the preset library, or paste a URL to auto-detect.
126+ </DialogDescription>
127+ </DialogHeader>
128+
129+ <div className="flex min-w-0 flex-col gap-5">
130+ <div className="flex flex-col gap-2">
131+ <div className="flex gap-2">
132+ <Input
133+ type="text"
134+ value={query}
135+ onChange={(event) => setQuery((event.target as HTMLInputElement).value)}
136+ placeholder="Search or paste a URL…"
137+ className="flex-1"
138+ />
139+ {isUrl && <Button disabled={query.trim().length === 0}>Detect</Button>}
140+ </div>
141+ </div>
142+
143+ <div className="flex flex-col gap-2">
144+ <p className="text-xs font-medium text-foreground/80">Or add manually</p>
145+ <div className="flex flex-wrap gap-2">
146+ {integrationPlugins.map((plugin) => (
147+ // oxlint-disable-next-line react/forbid-elements
148+ <button
149+ key={plugin.key}
150+ type="button"
151+ onClick={() => {
152+ close();
153+ props.onPickPlugin(plugin.key);
154+ }}
155+ className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
156+ >
157+ {plugin.label}
158+ </button>
159+ ))}
160+ </div>
161+ </div>
162+
163+ <div className="flex min-w-0 flex-col gap-2">
164+ <p className="text-xs font-medium text-foreground/80">Popular integrations</p>
165+ <CardStack className="min-w-0">
166+ <CardStackContent className="h-64 overflow-y-auto">
167+ {filtered.length === 0 && !showCatalogSection ? (
168+ <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-6 text-center">
169+ <p className="text-sm text-muted-foreground">No matching integrations</p>
170+ <p className="text-xs text-muted-foreground/70">
171+ Paste a URL above to auto-detect, or pick an integration type manually.
172+ </p>
173+ </div>
174+ ) : (
175+ filtered.map((preset) => (
176+ <CardStackEntry key={`${preset.pluginKey}-${preset.id}`} asChild>
177+ {/* oxlint-disable-next-line react/forbid-elements */}
178+ <button
179+ type="button"
180+ className="w-full text-left"
181+ onClick={() => {
182+ close();
183+ props.onPickPreset(preset);
184+ }}
185+ >
186+ <span className="flex size-8 shrink-0 items-center justify-center">
187+ {preset.icon ? (
188+ <img
189+ src={preset.icon}
190+ alt=""
191+ className="size-5 object-contain"
192+ loading="lazy"
193+ />
194+ ) : (
195+ <svg viewBox="0 0 16 16" className="size-3.5" fill="none">
196+ <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />
197+ </svg>
198+ )}
199+ </span>
200+ <CardStackEntryContent>
201+ <CardStackEntryTitle>{preset.name}</CardStackEntryTitle>
202+ <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription>
203+ </CardStackEntryContent>
204+ <CardStackEntryActions>
205+ <Badge variant="secondary">{preset.pluginLabel}</Badge>
206+ </CardStackEntryActions>
207+ </button>
208+ </CardStackEntry>
209+ ))
210+ )}
211+
212+ {showCatalogSection && (
213+ <>
214+ {catalog.entries.map((entry) => (
215+ <CardStackEntry key={`catalog-${entry.domain}`} asChild>
216+ {/* oxlint-disable-next-line react/forbid-elements */}
217+ <button
218+ type="button"
219+ className="w-full text-left"
220+ onClick={() => {
221+ close();
222+ props.onPickCatalogEntry(entry);
223+ }}
224+ >
225+ <span className="flex size-8 shrink-0 items-center justify-center">
226+ <img
227+ src={catalogLogoUrl(entry.domain, 10)}
228+ alt=""
229+ className="size-5 object-contain"
230+ loading="lazy"
231+ />
232+ </span>
233+ <CardStackEntryContent>
234+ <CardStackEntryTitle>{entry.domain}</CardStackEntryTitle>
235+ <CardStackEntryDescription>
236+ {entry.description}
237+ </CardStackEntryDescription>
238+ </CardStackEntryContent>
239+ <CardStackEntryActions>
240+ {entry.kinds.map((kind) => (
241+ <Badge key={kind} variant="secondary">
242+ {CATALOG_KIND_LABEL[kind]}
243+ </Badge>
244+ ))}
245+ </CardStackEntryActions>
246+ </button>
247+ </CardStackEntry>
248+ ))}
249+ {catalog.loading &&
250+ catalog.entries.length === 0 &&
251+ Array.from({ length: 3 }).map((_, i) => (
252+ <div
253+ key={`catalog-skeleton-${i}`}
254+ className="flex items-center gap-3 px-4 py-3"
255+ >
256+ <Skeleton className="size-5 shrink-0 rounded-md" />
257+ <div className="flex min-w-0 flex-1 flex-col gap-1.5">
258+ <Skeleton
259+ className="h-3.5"
260+ style={{ width: `${35 + ((i * 13) % 25)}%` }}
261+ />
262+ <Skeleton
263+ className="h-3"
264+ style={{ width: `${55 + ((i * 9) % 20)}%` }}
265+ />
266+ </div>
267+ <Skeleton className="h-5 w-14 rounded-full" />
268+ </div>
269+ ))}
270+ </>
271+ )}
272+ </CardStackContent>
273+ </CardStack>
274+ </div>
275+ </div>
276+ </DialogContent>
277+ </Dialog>
278+ );
279+}
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+
9+import { useState } from "react";
10+import { Button } from "@executor-js/react/components/button";
11+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs";
12+import type { DemoIntegration } from "../fixtures";
13+
14+export function IntegrationDetailPage(props: {
15+ readonly integration: DemoIntegration;
16+ readonly onAddConnection: () => void;
17+}) {
18+ const [tab, setTab] = useState<"accounts" | "tools">("accounts");
19+
20+ return (
21+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
22+ <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm">
23+ <div className="flex min-w-0 items-center gap-3">
24+ <h2 className="truncate text-sm font-semibold text-foreground">
25+ {props.integration.name}
26+ </h2>
27+ <span className="hidden text-xs tabular-nums text-muted-foreground sm:block">
28+ {props.integration.toolCount} tools
29+ </span>
30+ </div>
31+ <div className="flex shrink-0 items-center gap-2">
32+ <Button variant="outline" size="sm">
33+ Edit
34+ </Button>
35+ <Button variant="outline" size="sm">
36+ Refresh
37+ </Button>
38+ <Button
39+ variant="outline"
40+ size="sm"
41+ className="border-destructive/30 text-destructive hover:bg-destructive/10"
42+ >
43+ Delete
44+ </Button>
45+ </div>
46+ </div>
47+
48+ <Tabs
49+ value={tab}
50+ onValueChange={(value: string) => setTab(value === "tools" ? "tools" : "accounts")}
51+ className="min-h-0 flex-1 gap-0 overflow-hidden"
52+ >
53+ <div className="shrink-0 border-b border-border/60 px-4 py-2">
54+ <TabsList variant="line">
55+ <TabsTrigger value="accounts">Accounts</TabsTrigger>
56+ <TabsTrigger value="tools">Tools</TabsTrigger>
57+ </TabsList>
58+ </div>
59+
60+ <TabsContent value="accounts" className="min-h-0 overflow-y-auto">
61+ <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">
62+ <section className="space-y-3">
63+ <div className="flex items-center justify-between">
64+ <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
65+ Connections
66+ </h3>
67+ </div>
68+ <div className="rounded-lg border border-dashed border-border/60 px-6 py-8 text-center">
69+ <p className="text-sm font-medium text-foreground">No connections yet</p>
70+ <p className="mt-1 text-sm text-muted-foreground">
71+ Add a connection to make this integration&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+
5+import { PlusIcon } from "lucide-react";
6+import { Button } from "@executor-js/react/components/button";
7+import { PageContainer, PageHeader } from "@executor-js/react/components/page";
8+import {
9+ CardStack,
10+ CardStackContent,
11+ CardStackEntry,
12+ CardStackEntryActions,
13+ CardStackEntryContent,
14+ CardStackEntryDescription,
15+ CardStackEntryTitle,
16+ CardStackHeader,
17+} from "@executor-js/react/components/card-stack";
18+import { Badge } from "@executor-js/react/components/badge";
19+import { Tabs, TabsList, TabsTrigger } from "@executor-js/react/components/tabs";
20+import type { DemoIntegration } from "../fixtures";
21+
22+function McpInstallCard() {
23+ return (
24+ <CardStack>
25+ <Tabs value="http">
26+ <CardStackHeader
27+ className="items-start py-4"
28+ rightSlot={
29+ <TabsList>
30+ <TabsTrigger value="http">Remote HTTP</TabsTrigger>
31+ <TabsTrigger value="stdio">Standard I/O</TabsTrigger>
32+ </TabsList>
33+ }
34+ >
35+ <div className="flex min-w-0 flex-col gap-0.5">
36+ <span className="text-sm font-semibold text-foreground">Connect an agent</span>
37+ <span className="text-xs font-normal text-muted-foreground">
38+ Paste this into Claude Code, Cursor, or any MCP client, and your agent gets every tool
39+ you connect here.
40+ </span>
41+ </div>
42+ </CardStackHeader>
43+ <CardStackContent>
44+ <div className="px-4 pt-3 pb-3">
45+ <div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 font-mono text-xs text-foreground">
46+ npx add-mcp http://localhost:3123/mcp --transport http --name executor
47+ </div>
48+ <div className="mt-3 text-xs font-medium text-muted-foreground">Advanced ⌄</div>
49+ </div>
50+ <div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground">
51+ <span>Work with your agent</span>
52+ <span className="rounded-md border border-border/60 px-1.5 py-0.5">Cursor</span>
53+ <span className="rounded-md border border-border/60 px-1.5 py-0.5">Claude</span>
54+ <span className="rounded-md border border-border/60 px-1.5 py-0.5">OpenCode</span>
55+ <span>and more</span>
56+ </div>
57+ </CardStackContent>
58+ </Tabs>
59+ </CardStack>
60+ );
61+}
62+
63+export function IntegrationsPage(props: {
64+ readonly integrations: readonly DemoIntegration[];
65+ readonly onConnect: () => void;
66+ readonly onOpenIntegration: (slug: string) => void;
67+}) {
68+ return (
69+ <PageContainer>
70+ <PageHeader
71+ title="Integrations"
72+ description="Tool providers available in this workspace."
73+ actions={
74+ <Button onClick={props.onConnect} size="sm" className="gap-1.5">
75+ <PlusIcon className="size-4" />
76+ Connect
77+ </Button>
78+ }
79+ />
80+
81+ <div className="mb-8">
82+ <McpInstallCard />
83+ </div>
84+
85+ <div className="mb-8 border-t border-border/50" />
86+
87+ {props.integrations.length === 0 ? (
88+ <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">
89+ <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
90+ <PlusIcon className="size-5" />
91+ </div>
92+ <p className="mb-1 text-[14px] font-medium text-foreground/70">No integrations yet</p>
93+ <p className="mb-5 text-[13px] text-muted-foreground/60">
94+ Connect an integration to start curating tools.
95+ </p>
96+ <Button onClick={props.onConnect} size="sm" className="gap-1.5">
97+ <PlusIcon className="size-4" />
98+ Connect an integration
99+ </Button>
100+ </div>
101+ ) : (
102+ <div className="mb-8 space-y-3">
103+ <CardStack searchable>
104+ <CardStackContent>
105+ {props.integrations.map((integration) => (
106+ <CardStackEntry key={integration.slug} asChild>
107+ {/* oxlint-disable-next-line react/forbid-elements */}
108+ <button
109+ type="button"
110+ className="w-full text-left"
111+ onClick={() => props.onOpenIntegration(integration.slug)}
112+ >
113+ {integration.icon ? (
114+ <img
115+ src={integration.icon}
116+ alt=""
117+ className="size-8 shrink-0 rounded-md object-contain"
118+ />
119+ ) : (
120+ <span className="size-8 shrink-0 rounded-md bg-muted" />
121+ )}
122+ <CardStackEntryContent>
123+ <CardStackEntryTitle>{integration.name}</CardStackEntryTitle>
124+ <CardStackEntryDescription>{integration.slug}</CardStackEntryDescription>
125+ </CardStackEntryContent>
126+ <CardStackEntryActions>
127+ <Badge variant="outline">
128+ {integration.toolCount === 0
129+ ? "No connection"
130+ : `${integration.toolCount} tools`}
131+ </Badge>
132+ </CardStackEntryActions>
133+ </button>
134+ </CardStackEntry>
135+ ))}
136+ </CardStackContent>
137+ </CardStack>
138+ </div>
139+ )}
140+ </PageContainer>
141+ );
142+}
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+
14+import { useEffect, useState } from "react";
15+import { ArrowUpRightIcon, EyeIcon, EyeOffIcon } from "lucide-react";
16+import { Button } from "@executor-js/react/components/button";
17+import { Label } from "@executor-js/react/components/label";
18+import {
19+ Dialog,
20+ DialogContent,
21+ DialogDescription,
22+ DialogFooter,
23+ DialogHeader,
24+ DialogTitle,
25+} from "@executor-js/react/components/dialog";
26+import { fetchSurfaceCredentials, type SurfaceCredential } from "../../fixtures";
27+
28+/** The registry's setup text is markdown. Only links and bold appear in
29+ * practice, so render those two and leave the rest as text rather than
30+ * pulling in a markdown pipeline for a prototype. */
31+function SetupText(props: { readonly text: string }) {
32+ const nodes = props.text
33+ .split(/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`)/g)
34+ .filter((chunk) => chunk.length > 0)
35+ .map((chunk, index) => {
36+ const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(chunk);
37+ if (link) {
38+ return (
39+ <a
40+ key={index}
41+ href={link[2]}
42+ target="_blank"
43+ rel="noreferrer"
44+ className="font-medium text-foreground underline underline-offset-2"
45+ >
46+ {link[1]}
47+ </a>
48+ );
49+ }
50+ const bold = /^\*\*([^*]+)\*\*$/.exec(chunk);
51+ if (bold) {
52+ return (
53+ <span key={index} className="font-medium text-foreground">
54+ {bold[1]}
55+ </span>
56+ );
57+ }
58+ const code = /^`([^`]+)`$/.exec(chunk);
59+ if (code) {
60+ return (
61+ <code key={index} className="font-mono text-[0.9em] text-foreground">
62+ {code[1]}
63+ </code>
64+ );
65+ }
66+ return <span key={index}>{chunk}</span>;
67+ });
68+ return <p className="text-xs leading-relaxed text-muted-foreground">{nodes}</p>;
69+}
70+
71+export function AuthenticateDialog(props: {
72+ readonly open: boolean;
73+ readonly onOpenChange: (open: boolean) => void;
74+ readonly integrationName: string;
75+ readonly domain: string;
76+ readonly accountLabel: string;
77+ readonly onAuthenticated: () => void;
78+}) {
79+ const [credentials, setCredentials] = useState<readonly SurfaceCredential[] | null>(null);
80+ const [selectedId, setSelectedId] = useState<string | null>(null);
81+ const [secret, setSecret] = useState("");
82+ const [revealed, setRevealed] = useState(false);
83+ const [submitting, setSubmitting] = useState(false);
84+
85+ useEffect(() => {
86+ let live = true;
87+ void fetchSurfaceCredentials(props.domain).then((loaded) => {
88+ if (!live) return;
89+ setCredentials(loaded);
90+ setSelectedId(loaded[0]?.id ?? null);
91+ });
92+ return () => {
93+ live = false;
94+ };
95+ }, [props.domain]);
96+
97+ const selected = credentials?.find((candidate) => candidate.id === selectedId) ?? null;
98+ const isOAuth = selected?.type === "oauth2";
99+ const canSubmit = isOAuth || secret.trim().length > 0;
100+
101+ return (
102+ <Dialog open={props.open} onOpenChange={props.onOpenChange}>
103+ <DialogContent className="sm:max-w-lg">
104+ <DialogHeader>
105+ <DialogTitle>Connect {props.integrationName}</DialogTitle>
106+ <DialogDescription>
107+ Signing in as <span className="font-mono text-foreground">{props.accountLabel}</span>.
108+ </DialogDescription>
109+ </DialogHeader>
110+
111+ {credentials === null ? (
112+ <p className="py-6 text-sm text-muted-foreground">
113+ Checking how {props.domain} signs in…
114+ </p>
115+ ) : credentials.length === 0 ? (
116+ <p className="py-6 text-sm text-muted-foreground">
117+ The registry has no credential record for {props.domain} yet.
118+ </p>
119+ ) : (
120+ <div className="flex flex-col gap-4">
121+ {/* Only shown when the provider genuinely offers a choice. One way
122+ in renders no picker at all. */}
123+ {credentials.length > 1 && (
124+ <div className="flex gap-1.5">
125+ {credentials.map((credential) => (
126+ // oxlint-disable-next-line react/forbid-elements
127+ <button
128+ key={credential.id}
129+ type="button"
130+ onClick={() => setSelectedId(credential.id)}
131+ className={
132+ credential.id === selectedId
133+ ? "rounded-full border border-foreground/20 bg-foreground px-3 py-1 text-xs font-medium text-background"
134+ : "rounded-full border border-border px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
135+ }
136+ >
137+ {credential.label}
138+ </button>
139+ ))}
140+ </div>
141+ )}
142+
143+ {isOAuth ? (
144+ <p className="text-xs leading-relaxed text-muted-foreground">
145+ {props.integrationName} signs in through your browser. Nothing to paste.
146+ </p>
147+ ) : (
148+ <>
149+ <div className="space-y-2">
150+ <div className="flex items-center justify-between gap-3">
151+ <Label
152+ htmlFor="credential"
153+ className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
154+ >
155+ {selected?.label ?? "API key"}
156+ </Label>
157+ {selected?.generateUrl ? (
158+ <a
159+ href={selected.generateUrl}
160+ target="_blank"
161+ rel="noreferrer"
162+ className="flex items-center gap-1 text-xs font-medium text-foreground underline underline-offset-2"
163+ >
164+ Get a key
165+ <ArrowUpRightIcon className="size-3" aria-hidden />
166+ </a>
167+ ) : null}
168+ </div>
169+ <div className="flex items-center gap-1">
170+ {/* oxlint-disable-next-line react/forbid-elements */}
171+ <input
172+ id="credential"
173+ type={revealed ? "text" : "password"}
174+ autoComplete="off"
175+ // The one thing this screen is for.
176+ // oxlint-disable-next-line jsx_a11y/no-autofocus
177+ autoFocus
178+ value={secret}
179+ onChange={(event) => setSecret(event.target.value)}
180+ placeholder="Paste your key"
181+ className="h-10 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 font-mono text-sm outline-none transition-colors focus:border-ring dark:bg-input/30"
182+ />
183+ <Button
184+ type="button"
185+ variant="ghost"
186+ size="icon-sm"
187+ aria-label="Show key"
188+ aria-pressed={revealed}
189+ className="shrink-0 text-muted-foreground"
190+ onClick={() => setRevealed((value) => !value)}
191+ >
192+ {revealed ? <EyeOffIcon /> : <EyeIcon />}
193+ </Button>
194+ </div>
195+ </div>
196+
197+ {selected?.setup ? (
198+ <div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
199+ <SetupText text={selected.setup} />
200+ </div>
201+ ) : null}
202+ </>
203+ )}
204+ </div>
205+ )}
206+
207+ <DialogFooter>
208+ <Button type="button" variant="ghost" onClick={() => props.onOpenChange(false)}>
209+ Cancel
210+ </Button>
211+ <Button
212+ type="button"
213+ disabled={!canSubmit || submitting}
214+ loading={submitting}
215+ onClick={() => {
216+ setSubmitting(true);
217+ setTimeout(() => {
218+ setSubmitting(false);
219+ props.onAuthenticated();
220+ }, 700);
221+ }}
222+ >
223+ {isOAuth ? `Continue to ${props.integrationName}` : "Connect"}
224+ </Button>
225+ </DialogFooter>
226+ </DialogContent>
227+ </Dialog>
228+ );
229+}
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+
13+import { useEffect, useMemo, useState } from "react";
14+import { CheckIcon, PlusIcon, SearchIcon } from "lucide-react";
15+import { Button } from "@executor-js/react/components/button";
16+import { Input } from "@executor-js/react/components/input";
17+import { Skeleton } from "@executor-js/react/components/skeleton";
18+import { cn } from "@executor-js/react/lib/utils";
19+import { CATEGORY_ORDER, loadCatalog, searchItems, type CatalogItem } from "../../catalog";
20+
21+const SECTION_SIZE = 6;
22+
23+function AddButton(props: { readonly added: boolean; readonly onAdd: () => void }) {
24+ if (props.added) {
25+ return (
26+ <span className="flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-xs font-medium text-muted-foreground">
27+ <CheckIcon className="size-3.5" aria-hidden />
28+ Added
29+ </span>
30+ );
31+ }
32+ return (
33+ <Button type="button" variant="outline" size="sm" className="shrink-0" onClick={props.onAdd}>
34+ Add
35+ </Button>
36+ );
37+}
38+
39+function CatalogRow(props: {
40+ readonly item: CatalogItem;
41+ readonly added: boolean;
42+ readonly onAdd: () => void;
43+ readonly onOpen: () => void;
44+}) {
45+ return (
46+ <div
47+ className={cn(
48+ "flex items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors",
49+ props.added ? "border-ring/40 bg-accent/40" : "border-transparent hover:bg-accent/40",
50+ )}
51+ >
52+ {/* oxlint-disable-next-line react/forbid-elements */}
53+ <button
54+ type="button"
55+ onClick={props.onOpen}
56+ className="flex min-w-0 flex-1 items-center gap-3 text-left"
57+ >
58+ <img
59+ src={props.item.icon}
60+ alt=""
61+ loading="lazy"
62+ className="size-8 shrink-0 rounded-md object-contain"
63+ />
64+ <span className="min-w-0 flex-1">
65+ <span className="block truncate text-sm font-medium text-foreground">
66+ {props.item.name}
67+ </span>
68+ <span className="block truncate text-xs text-muted-foreground">
69+ {props.item.description}
70+ </span>
71+ </span>
72+ </button>
73+ <AddButton added={props.added} onAdd={props.onAdd} />
74+ </div>
75+ );
76+}
77+
78+/** Offered whenever the query cannot be satisfied from the registry — either
79+ * because it looks like a URL, or because nothing matched. */
80+function CustomPrompt(props: { readonly query: string; readonly onAddCustom: () => void }) {
81+ const looksLikeUrl = /^[a-z]+:\/\//i.test(props.query) || props.query.includes("/");
82+ return (
83+ <div className="flex items-center gap-3 rounded-lg border border-dashed border-border px-3 py-3">
84+ <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
85+ <PlusIcon className="size-4" aria-hidden />
86+ </span>
87+ <div className="min-w-0 flex-1">
88+ <p className="text-sm font-medium text-foreground">
89+ {looksLikeUrl ? "Add this URL directly" : "Not in the registry?"}
90+ </p>
91+ <p className="text-xs text-muted-foreground">
92+ Point executor at any MCP server, OpenAPI spec, or GraphQL endpoint.
93+ </p>
94+ </div>
95+ <Button type="button" size="sm" className="shrink-0" onClick={props.onAddCustom}>
96+ Add custom
97+ </Button>
98+ </div>
99+ );
100+}
101+
102+export function BrowsePage(props: {
103+ readonly addedDomains: readonly string[];
104+ readonly onAdd: (item: CatalogItem) => void;
105+ readonly onOpen: (item: CatalogItem) => void;
106+ readonly onAddCustom: () => void;
107+}) {
108+ const [items, setItems] = useState<readonly CatalogItem[] | null>(null);
109+ const [query, setQuery] = useState("");
110+ const [category, setCategory] = useState<string>("All");
111+
112+ useEffect(() => {
113+ let live = true;
114+ void loadCatalog().then((loaded) => {
115+ if (live) setItems(loaded);
116+ });
117+ return () => {
118+ live = false;
119+ };
120+ }, []);
121+
122+ const searched = useMemo(() => (items ? searchItems(items, query) : []), [items, query]);
123+ const searching = query.trim().length > 0;
124+
125+ const sections = useMemo(() => {
126+ if (searching) return [];
127+ const wanted = category === "All" ? CATEGORY_ORDER : [category];
128+ return wanted
129+ .map((name) => ({
130+ name,
131+ items: searched.filter((item) => item.category === name).slice(0, SECTION_SIZE),
132+ }))
133+ .filter((section) => section.items.length > 0);
134+ }, [searched, category, searching]);
135+
136+ return (
137+ <>
138+ <div className="mb-4 flex gap-2">
139+ <div className="relative min-w-0 flex-1">
140+ <SearchIcon
141+ className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
142+ aria-hidden
143+ />
144+ <Input
145+ value={query}
146+ onChange={(event) => setQuery((event.target as HTMLInputElement).value)}
147+ placeholder="Search integrations…"
148+ aria-label="Search integrations"
149+ className="h-11 pl-9 text-sm"
150+ />
151+ </div>
152+ <Button
153+ type="button"
154+ variant="outline"
155+ className="h-11 shrink-0 gap-1.5"
156+ onClick={props.onAddCustom}
157+ >
158+ <PlusIcon className="size-4" aria-hidden />
159+ Add custom
160+ </Button>
161+ </div>
162+
163+ <div className="mb-8 flex flex-wrap gap-1.5">
164+ {["All", ...CATEGORY_ORDER].map((name) => (
165+ // oxlint-disable-next-line react/forbid-elements
166+ <button
167+ key={name}
168+ type="button"
169+ onClick={() => setCategory(name)}
170+ className={cn(
171+ "rounded-full border px-3 py-1 text-xs font-medium transition-colors",
172+ category === name
173+ ? "border-foreground/20 bg-foreground text-background"
174+ : "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
175+ )}
176+ >
177+ {name}
178+ </button>
179+ ))}
180+ </div>
181+
182+ {items === null ? (
183+ <div className="flex flex-col gap-2">
184+ {Array.from({ length: 8 }).map((_, i) => (
185+ <div key={i} className="flex items-center gap-3 px-3 py-2.5">
186+ <Skeleton className="size-8 shrink-0 rounded-md" />
187+ <div className="flex min-w-0 flex-1 flex-col gap-1.5">
188+ <Skeleton className="h-3.5" style={{ width: `${20 + ((i * 11) % 20)}%` }} />
189+ <Skeleton className="h-3" style={{ width: `${45 + ((i * 13) % 30)}%` }} />
190+ </div>
191+ <Skeleton className="h-8 w-14 rounded-md" />
192+ </div>
193+ ))}
194+ </div>
195+ ) : searching ? (
196+ <div className="flex flex-col gap-1">
197+ {searched.slice(0, 40).map((item) => (
198+ <CatalogRow
199+ key={item.domain}
200+ item={item}
201+ added={props.addedDomains.includes(item.domain)}
202+ onAdd={() => props.onAdd(item)}
203+ onOpen={() => props.onOpen(item)}
204+ />
205+ ))}
206+ <div className="mt-2">
207+ <CustomPrompt query={query} onAddCustom={props.onAddCustom} />
208+ </div>
209+ </div>
210+ ) : (
211+ <div className="flex flex-col gap-8">
212+ {sections.map((section) => (
213+ <section key={section.name}>
214+ <h2 className="mb-2 px-3 text-xs font-medium uppercase tracking-widest text-muted-foreground">
215+ {section.name}
216+ </h2>
217+ <div className="flex flex-col gap-1">
218+ {section.items.map((item) => (
219+ <CatalogRow
220+ key={item.domain}
221+ item={item}
222+ added={props.addedDomains.includes(item.domain)}
223+ onAdd={() => props.onAdd(item)}
224+ onOpen={() => props.onOpen(item)}
225+ />
226+ ))}
227+ </div>
228+ </section>
229+ ))}
230+ </div>
231+ )}
232+ </>
233+ );
234+}
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+
12+import { useState } from "react";
13+import { ArrowUpRightIcon } from "lucide-react";
14+import { Button } from "@executor-js/react/components/button";
15+import { Label } from "@executor-js/react/components/label";
16+import {
17+ Dialog,
18+ DialogContent,
19+ DialogDescription,
20+ DialogFooter,
21+ DialogHeader,
22+ DialogTitle,
23+} from "@executor-js/react/components/dialog";
24+import { getDomain } from "tldts";
25+import { logoFor, type AddedIntegration } from "../../catalog";
26+import { INTEGRATIONS_SH_ORIGIN } from "../../fixtures";
27+
28+type Kind = "mcp" | "openapi" | "graphql";
29+
30+const KIND_LABEL: Record<Kind, string> = {
31+ mcp: "MCP server",
32+ openapi: "OpenAPI spec",
33+ graphql: "GraphQL endpoint",
34+};
35+
36+interface Detected {
37+ readonly kind: Kind;
38+ readonly domain: string;
39+ readonly url: string;
40+ /** A registry entry related to this URL, if there is one. `exact` separates
41+ * two genuinely different situations that a string match cannot tell apart
42+ * on its own: the host IS the registry entry, versus the host merely sits
43+ * under a domain the registry knows. `mcp.posthog.com` under `posthog.com`
44+ * is probably the same service; `internal.acme.com` under `acme.com` is
45+ * probably not, so the copy must not claim it is. */
46+ readonly registryDomain: string | null;
47+ readonly exact: boolean;
48+}
49+
50+const guessKind = (url: URL): Kind => {
51+ const path = `${url.pathname}${url.search}`.toLowerCase();
52+ if (path.includes("graphql")) return "graphql";
53+ if (/\/mcp\b|\/sse\b/.test(path)) return "mcp";
54+ if (/openapi|swagger|\.ya?ml$|\.json$/.test(path)) return "openapi";
55+ // A bare origin is most often an MCP server these days; the detect step in
56+ // the real product probes rather than guesses.
57+ return path === "/" || path.length === 0 ? "mcp" : "openapi";
58+};
59+
60+const detect = async (raw: string): Promise<Detected | { readonly error: string }> => {
61+ const trimmed = raw.trim();
62+ const withScheme = /^[a-z][a-z0-9+\-.]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
63+ const parsed = ((): URL | null => {
64+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL parsing reports failure by throwing
65+ try {
66+ return new URL(withScheme);
67+ } catch {
68+ return null;
69+ }
70+ })();
71+ if (!parsed) return { error: "That doesn't look like a URL." };
72+
73+ const host = parsed.hostname.replace(/^www\./, "");
74+ // The registry keys on the registrable domain, so an endpoint living on a
75+ // subdomain (`mcp.posthog.com`) only matches once we also try its parent.
76+ const candidates = [...new Set([host, getDomain(host)])].filter(
77+ (candidate): candidate is string => candidate !== null && candidate.length > 0,
78+ );
79+ const registryDomain = await (async () => {
80+ for (const candidate of candidates) {
81+ // A miss and a network failure mean the same thing here — nothing extra
82+ // to offer — so both just move on to the next candidate.
83+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch reports transport failure by rejecting
84+ try {
85+ const response = await fetch(
86+ `${INTEGRATIONS_SH_ORIGIN}/api/${encodeURIComponent(candidate)}/surface`,
87+ );
88+ if (response.ok) return candidate;
89+ } catch {
90+ continue;
91+ }
92+ }
93+ return null;
94+ })();
95+
96+ return {
97+ kind: guessKind(parsed),
98+ domain: host,
99+ url: parsed.toString(),
100+ registryDomain,
101+ exact: registryDomain === host,
102+ };
103+};
104+
105+export function AddCustomDialog(props: {
106+ readonly open: boolean;
107+ readonly onOpenChange: (open: boolean) => void;
108+ readonly onAdd: (integration: AddedIntegration) => void;
109+}) {
110+ const [raw, setRaw] = useState("");
111+ const [busy, setBusy] = useState(false);
112+ const [result, setResult] = useState<Detected | null>(null);
113+ const [error, setError] = useState<string | null>(null);
114+
115+ const run = async () => {
116+ setBusy(true);
117+ setError(null);
118+ const outcome = await detect(raw);
119+ setBusy(false);
120+ if ("error" in outcome) {
121+ setError(outcome.error);
122+ setResult(null);
123+ return;
124+ }
125+ setResult(outcome);
126+ };
127+
128+ return (
129+ <Dialog open={props.open} onOpenChange={props.onOpenChange}>
130+ <DialogContent className="sm:max-w-lg">
131+ <DialogHeader>
132+ <DialogTitle>Add a custom integration</DialogTitle>
133+ <DialogDescription>
134+ Point at an MCP server, an OpenAPI spec, or a GraphQL endpoint. It does not need to be
135+ in the registry.
136+ </DialogDescription>
137+ </DialogHeader>
138+
139+ <div className="flex flex-col gap-4">
140+ <div className="space-y-2">
141+ <Label
142+ htmlFor="custom-url"
143+ className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
144+ >
145+ URL
146+ </Label>
147+ <div className="flex gap-2">
148+ {/* oxlint-disable-next-line react/forbid-elements */}
149+ <input
150+ id="custom-url"
151+ // oxlint-disable-next-line jsx_a11y/no-autofocus -- the only field on the screen
152+ autoFocus
153+ value={raw}
154+ onChange={(event) => {
155+ setRaw(event.target.value);
156+ setResult(null);
157+ setError(null);
158+ }}
159+ onKeyDown={(event) => {
160+ if (event.key === "Enter" && raw.trim().length > 0) void run();
161+ }}
162+ placeholder="https://internal.acme.com/mcp"
163+ className="h-10 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 font-mono text-sm outline-none transition-colors focus:border-ring dark:bg-input/30"
164+ />
165+ <Button
166+ type="button"
167+ variant="outline"
168+ disabled={raw.trim().length === 0 || busy}
169+ loading={busy}
170+ onClick={() => void run()}
171+ >
172+ Check
173+ </Button>
174+ </div>
175+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
176+ </div>
177+
178+ {result ? (
179+ <div className="space-y-2 rounded-lg border border-border bg-muted/20 px-3 py-3">
180+ <div className="flex items-center gap-2.5">
181+ <img
182+ src={logoFor(result.domain)}
183+ alt=""
184+ className="size-6 shrink-0 rounded object-contain"
185+ />
186+ <div className="min-w-0 flex-1">
187+ <p className="truncate text-sm font-medium text-foreground">{result.domain}</p>
188+ <p className="text-xs text-muted-foreground">{KIND_LABEL[result.kind]}</p>
189+ </div>
190+ </div>
191+ {result.registryDomain ? (
192+ <p className="text-xs leading-relaxed text-muted-foreground">
193+ {result.exact
194+ ? `The registry already covers ${result.registryDomain}. Adding it from search brings the credential setup instructions with it — this URL will be added as-is, with none of that.`
195+ : `The registry has an entry for ${result.registryDomain}, which may or may not be the same service as this host. This URL will be added as-is either way.`}{" "}
196+ <a
197+ href={`${INTEGRATIONS_SH_ORIGIN}/${result.registryDomain}/`}
198+ target="_blank"
199+ rel="noreferrer"
200+ className="inline-flex items-center gap-0.5 font-medium text-foreground underline underline-offset-2"
201+ >
202+ See the entry
203+ <ArrowUpRightIcon className="size-3" aria-hidden />
204+ </a>
205+ </p>
206+ ) : (
207+ <p className="text-xs leading-relaxed text-muted-foreground">
208+ Not in the registry. You&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+
13+import { ArrowLeftIcon, ArrowUpRightIcon, ChevronDownIcon, PlusIcon } from "lucide-react";
14+import { Button } from "@executor-js/react/components/button";
15+import { cn } from "@executor-js/react/lib/utils";
16+import type { AddedIntegration } from "../../catalog";
17+
18+export interface DemoAccount {
19+ readonly label: string;
20+ readonly status: "needs-auth" | "connected";
21+ readonly identity?: string;
22+}
23+
24+function AccountRow(props: { readonly account: DemoAccount; readonly onAuthenticate: () => void }) {
25+ const needsAuth = props.account.status === "needs-auth";
26+ return (
27+ <div className="flex items-center gap-3 px-3 py-2.5">
28+ <span
29+ className={cn(
30+ "size-2 shrink-0 rounded-full",
31+ needsAuth ? "bg-amber-500" : "bg-emerald-500",
32+ )}
33+ aria-hidden
34+ />
35+ <span className="min-w-0 flex-1">
36+ <span className="block truncate text-sm text-foreground">
37+ {props.account.identity ?? props.account.label}
38+ </span>
39+ </span>
40+ {needsAuth ? (
41+ <>
42+ <span className="shrink-0 text-xs font-medium text-amber-500">Needs auth</span>
43+ <Button type="button" variant="outline" size="sm" onClick={props.onAuthenticate}>
44+ Authenticate
45+ </Button>
46+ </>
47+ ) : (
48+ <span className="shrink-0 text-xs text-muted-foreground">Connected</span>
49+ )}
50+ </div>
51+ );
52+}
53+
54+export function NextIntegrationDetail(props: {
55+ readonly item: AddedIntegration;
56+ readonly accounts: readonly DemoAccount[];
57+ readonly toolCount: number;
58+ readonly onBack: () => void;
59+ readonly onAuthenticate: (accountLabel: string) => void;
60+ readonly onAddAccount: () => void;
61+ readonly onRemove: () => void;
62+}) {
63+ const custom = props.item.source === "custom";
64+ return (
65+ <>
66+ <Button
67+ type="button"
68+ variant="ghost"
69+ size="sm"
70+ className="-ml-2 mb-6 gap-1.5 text-muted-foreground"
71+ onClick={props.onBack}
72+ >
73+ <ArrowLeftIcon className="size-4" />
74+ All integrations
75+ </Button>
76+
77+ <div className="mb-6 flex items-start gap-4">
78+ <img src={props.item.icon} alt="" className="size-12 shrink-0 rounded-xl object-contain" />
79+ <div className="min-w-0 flex-1">
80+ <h1 className="text-lg font-semibold text-foreground">{props.item.name}</h1>
81+ {custom ? (
82+ <p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
83+ {props.item.url}
84+ </p>
85+ ) : (
86+ <a
87+ href={`https://integrations.sh/${props.item.domain}/`}
88+ target="_blank"
89+ rel="noreferrer"
90+ className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
91+ >
92+ {props.item.domain}
93+ <ArrowUpRightIcon className="size-3" aria-hidden />
94+ </a>
95+ )}
96+ </div>
97+ <Button type="button" variant="outline" size="sm" onClick={props.onRemove}>
98+ Remove
99+ </Button>
100+ </div>
101+
102+ <p className="mb-8 text-sm leading-relaxed text-muted-foreground">{props.item.description}</p>
103+
104+ <section className="mb-6">
105+ <h2 className="mb-2 text-xs font-medium uppercase tracking-widest text-muted-foreground">
106+ Accounts
107+ </h2>
108+ <div className="overflow-hidden rounded-lg border border-border">
109+ <div className="divide-y divide-border">
110+ {props.accounts.map((account) => (
111+ <AccountRow
112+ key={account.label}
113+ account={account}
114+ onAuthenticate={() => props.onAuthenticate(account.label)}
115+ />
116+ ))}
117+ </div>
118+ {/* oxlint-disable-next-line react/forbid-elements */}
119+ <button
120+ type="button"
121+ onClick={props.onAddAccount}
122+ className="flex w-full items-center gap-1.5 border-t border-border px-3 py-2.5 text-left text-xs font-medium text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
123+ >
124+ <PlusIcon className="size-3.5" aria-hidden />
125+ Add another account
126+ </button>
127+ </div>
128+ </section>
129+
130+ <section>
131+ <div className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5">
132+ <span className="text-sm text-foreground">
133+ {props.toolCount === 0
134+ ? "Tools appear once an account is connected"
135+ : `${props.toolCount} tools`}
136+ </span>
137+ <ChevronDownIcon className="size-4 text-muted-foreground" aria-hidden />
138+ </div>
139+ </section>
140+ </>
141+ );
142+}
143+
144+/** The installed list. Without a sidebar this is how you get back to something
145+ * you added, so it carries the same status vocabulary as the detail screen. */
146+export function InstalledList(props: {
147+ readonly items: readonly {
148+ readonly item: AddedIntegration;
149+ readonly accounts: readonly DemoAccount[];
150+ }[];
151+ readonly onOpen: (domain: string) => void;
152+ readonly onBrowse: () => void;
153+}) {
154+ if (props.items.length === 0) {
155+ return (
156+ <div className="rounded-lg border border-dashed border-border py-16 text-center">
157+ <p className="text-sm font-medium text-foreground">Nothing added yet</p>
158+ <p className="mt-1 text-sm text-muted-foreground">
159+ Add an integration and it shows up here.
160+ </p>
161+ <Button type="button" size="sm" className="mt-4" onClick={props.onBrowse}>
162+ Browse integrations
163+ </Button>
164+ </div>
165+ );
166+ }
167+
168+ return (
169+ <div className="flex flex-col gap-1">
170+ {props.items.map(({ item, accounts }) => {
171+ const pending = accounts.filter((account) => account.status === "needs-auth").length;
172+ return (
173+ // oxlint-disable-next-line react/forbid-elements
174+ <button
175+ key={item.domain}
176+ type="button"
177+ onClick={() => props.onOpen(item.domain)}
178+ className="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors hover:bg-accent/40"
179+ >
180+ <img
181+ src={item.icon}
182+ alt=""
183+ loading="lazy"
184+ className="size-8 shrink-0 rounded-md object-contain"
185+ />
186+ <span className="min-w-0 flex-1">
187+ <span className="flex items-center gap-2">
188+ <span className="truncate text-sm font-medium text-foreground">{item.name}</span>
189+ {item.source === "custom" ? (
190+ <span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-muted-foreground">
191+ Custom
192+ </span>
193+ ) : null}
194+ </span>
195+ <span className="block truncate text-xs text-muted-foreground">
196+ {accounts.length} account{accounts.length === 1 ? "" : "s"}
197+ </span>
198+ </span>
199+ {pending > 0 ? (
200+ <span className="shrink-0 text-xs font-medium text-amber-500">Needs auth</span>
201+ ) : (
202+ <span className="shrink-0 text-xs text-muted-foreground">Connected</span>
203+ )}
204+ </button>
205+ );
206+ })}
207+ </div>
208+ );
209+}
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+
5+import type { ReactNode } from "react";
6+import { cn } from "@executor-js/react/lib/utils";
7+import type { DemoIntegration } from "./fixtures";
8+
9+const NAV_ITEMS = ["Integrations", "Secrets", "Policies", "Toolkits", "Artifacts"] as const;
10+
11+function NavItem(props: { readonly label: string; readonly active: boolean }) {
12+ return (
13+ <span
14+ className={cn(
15+ "flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
16+ props.active
17+ ? "bg-sidebar-active font-medium text-foreground"
18+ : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
19+ )}
20+ >
21+ {props.label}
22+ </span>
23+ );
24+}
25+
26+export function Shell(props: {
27+ readonly integrations: readonly DemoIntegration[];
28+ readonly children: ReactNode;
29+}) {
30+ return (
31+ <div className="flex h-full min-h-0 w-full">
32+ <aside className="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
33+ <div className="flex h-12 shrink-0 items-center gap-2 border-b border-sidebar-border px-4">
34+ <span className="font-mono text-sm font-medium tracking-tight text-foreground">
35+ executor
36+ </span>
37+ <span className="ml-auto truncate font-mono text-[11px] text-muted-foreground">
38+ theo&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+
5+import type { StepId } from "./flow";
6+import { gmailIntegration, posthogIntegration, type DemoIntegration } from "./fixtures";
7+
8+export type AccountModal = "none" | "credential" | "place" | "oauth-stuck";
9+export type ScreenView = "integrations" | "add" | "detail";
10+
11+export interface StepState {
12+ readonly integrations: readonly DemoIntegration[];
13+ readonly connectOpen: boolean;
14+ readonly accountModal: AccountModal;
15+ readonly view: ScreenView;
16+}
17+
18+const EMPTY: StepState = {
19+ integrations: [],
20+ connectOpen: false,
21+ accountModal: "none",
22+ view: "integrations",
23+};
24+
25+const WITH_POSTHOG: StepState = {
26+ integrations: [posthogIntegration],
27+ connectOpen: false,
28+ accountModal: "none",
29+ view: "detail",
30+};
31+
32+export const stepState: Readonly<Record<StepId, StepState>> = {
33+ "integrations-empty": EMPTY,
34+ "connect-dialog": { ...EMPTY, connectOpen: true },
35+ "add-openapi": { ...EMPTY, view: "add" },
36+ "detail-accounts": WITH_POSTHOG,
37+ "add-account-credential": { ...WITH_POSTHOG, accountModal: "credential" },
38+ "add-account-place": { ...WITH_POSTHOG, accountModal: "place" },
39+ "oauth-stuck": {
40+ ...WITH_POSTHOG,
41+ integrations: [gmailIntegration],
42+ accountModal: "oauth-stuck",
43+ },
44+ "integrations-populated": { ...WITH_POSTHOG, view: "integrations" },
45+};
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 @@
1+import { defineConfig } from "vite";
2+import react from "@vitejs/plugin-react";
3+import tailwindcss from "@tailwindcss/vite";
4+
5+// Standalone SPA reproduction of the current add-integration flow. No executor
6+// API, no auth, no backend — every screen runs off `src/fixtures.ts` plus the
7+// live public integrations.sh catalog, so the flow can be redesigned without
8+// standing up a tenant.
9+export default defineConfig({
10+ plugins: [react(), tailwindcss()],
11+ server: { port: 5199 },
12+});
packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx+19−2
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
1515 type AuthMethodRow,
1616 type AuthMethodSeed,
1717 } from "@executor-js/react/components/auth-method-list-editor";
18+import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
1819 import { FloatActions } from "@executor-js/react/components/float-actions";
1920 import {
2021 addIntegrationErrorMessage,
@@ -42,13 +43,25 @@ export default function AddGraphqlIntegration(props: { · 21 unmodified lines
4243 onComplete: (slug?: string) => void;
4344 onCancel: () => void;
4445 initialUrl?: string;
46+ initialAuthHeader?: string;
47+ initialAuthNote?: string;
4548 }) {
4649 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");
4750 const [description, setDescription] = useState("");
4851 const identity = useIntegrationIdentity({
4952 fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "",
5053 });
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);
5265 const [adding, setAdding] = useState(false);
5366 const [addError, setAddError] = useState<string | null>(null);
5467
@@ -144,9 +157,13 @@ export default function AddGraphqlIntegration(props: { · 89 unmodified lines
144157 list={authMethodList}
145158 allowedKinds={["none", "apikey"]}
146159 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."
148161 />
149162
163+ {props.initialAuthNote ? (
164+ <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p>
165+ ) : null}
166+
150167 {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />}
151168
152169 {addError && <FormErrorAlert message={addError} />}
packages/plugins/graphql/src/react/integration-plugin.ts+47−3
@@ -1,6 +1,49 @@
1−import { lazy } from "react";
2−import type { IntegrationPlugin } from "@executor-js/sdk/client";
1+import { lazy, useCallback } from "react";
2+import { useAtomSet } from "@effect/atom-react";
3+import * as Exit from "effect/Exit";
4+import type {
5+ IntegrationPlugin,
6+ IntegrationQuickAddInput,
7+ IntegrationQuickAddResult,
8+} from "@executor-js/sdk/client";
9+import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
10+import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";
11+import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
312 import { graphqlPresets } from "../sdk/presets";
13+import { createGraphqlIntegrationOptimistic } from "./atoms";
14+import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config";
15+
16+/** One-click add for a registry GraphQL row. A GraphQL endpoint cannot
17+ * describe its own auth, so the registry's declared header pattern is the
18+ * only carrier of facts like Linear's no-Bearer key — it becomes the
19+ * declared method, exactly as the add page would seed it. */
20+function useGraphqlQuickAdd(): (
21+ input: IntegrationQuickAddInput,
22+) => Promise<IntegrationQuickAddResult> {
23+ const doAdd = useAtomSet(createGraphqlIntegrationOptimistic, { mode: "promiseExit" });
24+ return useCallback(
25+ async (input) => {
26+ const slug = slugifyNamespace(input.slug ?? input.name);
27+ if (!slug) return { ok: false, reason: "no derivable slug" };
28+ const placement = input.authHeader ? placementFromHeaderPattern(input.authHeader) : null;
29+ const authenticationTemplate = placement
30+ ? graphqlAuthMethodInputsFromPlacements([placement])
31+ : [];
32+ const exit = await doAdd({
33+ payload: {
34+ endpoint: input.url,
35+ slug,
36+ name: input.name,
37+ ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}),
38+ },
39+ reactivityKeys: integrationWriteKeys,
40+ });
41+ if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" };
42+ return { ok: true, slug: String(exit.value.slug) };
43+ },
44+ [doAdd],
45+ );
46+}
447
548 const importAdd = () => import("./AddGraphqlIntegration");
649 const importAccounts = () => import("./GraphqlAccountsPanel");
@@ -12,9 +55,10 @@ export const graphqlIntegrationPlugin: IntegrationPlugin = { · 5 unmodified lines
1255 label: "GraphQL",
1356 add: lazy(importAdd),
1457 accounts: lazy(importAccounts),
15− presets: graphqlPresets,
58+ presets: graphqlPresets.map((preset) => ({ ...preset, registryListed: true })),
1659 preload: () => {
1760 void importAdd();
1861 void importAccounts();
1962 },
63+ useQuickAdd: useGraphqlQuickAdd,
2064 };
packages/plugins/mcp/src/api/group.ts+11−0
@@ -36,6 +36,11 @@ const AddRemoteServerPayload = Schema.Struct({ · 35 unmodified lines
3636 description: Schema.optional(Schema.String),
3737 endpoint: Schema.String,
3838 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"])),
3944 slug: Schema.optional(Schema.String),
4045 queryParams: Schema.optional(StringMap),
4146 headers: Schema.optional(StringMap),
@@ -100,6 +105,12 @@ const ProbeEndpointResponse = Schema.Struct({ · 58 unmodified lines
100105 serverName: Schema.NullOr(Schema.String),
101106 /** Server `instructions` from initialize — prefills the description field. */
102107 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"])),
103114 });
104115
105116 // ---------------------------------------------------------------------------
packages/plugins/mcp/src/api/handlers.test.ts+33−0
@@ -120,4 +120,37 @@ describe("McpHandlers", () => { · 119 unmodified lines
120120 expect(body.message).toContain("Do you need to provide an API key");
121121 }),
122122 );
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+ );
123156 });
packages/plugins/mcp/src/api/handlers.ts+2−0
@@ -71,6 +71,7 @@ const toServerInput = ( · 70 unmodified lines
7171 description?: string;
7272 endpoint: string;
7373 remoteTransport?: "streamable-http" | "sse" | "auto";
74+ versionNegotiation?: "auto" | "legacy";
7475 queryParams?: Record<string, string>;
7576 headers?: Record<string, string>;
7677 slug?: string;
@@ -87,6 +88,7 @@ const toServerInput = ( · 10 unmodified lines
8788 description: p.description,
8889 endpoint: p.endpoint,
8990 remoteTransport: p.remoteTransport,
91+ versionNegotiation: p.versionNegotiation,
9092 queryParams: p.queryParams,
9193 headers: p.headers,
9294 slug: p.slug,
packages/plugins/mcp/src/react/AddMcpIntegration.tsx+32−25
@@ -37,10 +37,15 @@ import { · 36 unmodified lines
3737 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
3838 import type { McpAuthMethodInput } from "../sdk/types";
3939 import { probeMcpEndpoint, addMcpServer } from "./atoms";
40+import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
4041 import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields";
4142 import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
4243 import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";
43−import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
44+import {
45+ mcpAuthMethodInputFromEditorValue,
46+ mcpDetectedAuthSeeds,
47+ mcpWireAuthInput,
48+} from "./auth-method-config";
4449 import CodexPluginAdd from "./CodexPluginAdd";
4550 import { parseStdioArgs } from "./stdio-fields";
4651 import { isProbableMcpEndpoint } from "./probe-url";
@@ -79,6 +84,8 @@ type ProbeResult = { · 32 unmodified lines
7984 toolCount: number | null;
8085 serverName: string | null;
8186 instructions: string | null;
87+ /** "legacy" when only the legacy handshake worked (version-echoing server). */
88+ versionNegotiation?: "auto" | "legacy";
8289 };
8390
8491 type State =
@@ -161,6 +168,9 @@ export default function AddMcpIntegration(props: { · 76 unmodified lines
161168 onCancel: () => void;
162169 initialUrl?: string;
163170 initialPreset?: string;
171+ initialAuthHeader?: string;
172+ initialAuthNote?: string;
173+ initialAuthKind?: string;
164174 /** Whether the stdio transport is enabled on the server. */
165175 allowStdio?: boolean;
166176 }) {
@@ -219,29 +229,16 @@ export default function AddMcpIntegration(props: { · 52 unmodified lines
219229 // The probe seeds the method list: detected OAuth → an OAuth row; a 401
220230 // without OAuth metadata → a bearer-header row; an open server → a no-auth
221231 // row. The user can edit any row or add alternate methods alongside.
222− const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => {
223− if (!probe) return [];
224− if (probe.requiresOAuth) {
225− return [
226− {
227− value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] },
228− label: "Detected",
229− },
230− ];
231− }
232− if (probe.requiresAuthentication) {
233− return [
234− {
235− value: {
236− kind: "apikey",
237− placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
238− },
239− label: "Detected",
240− },
241− ];
242− }
243− return [{ value: { kind: "none" }, label: "Detected" }];
244− }, [probe]);
232+ const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(
233+ () =>
234+ mcpDetectedAuthSeeds(probe, {
235+ placement: props.initialAuthHeader
236+ ? placementFromHeaderPattern(props.initialAuthHeader)
237+ : null,
238+ kind: props.initialAuthKind,
239+ }),
240+ [probe, props.initialAuthHeader, props.initialAuthKind],
241+ );
245242 const authMethodList = useAuthMethodList(authMethodSeeds);
246243
247244 const remoteIdentity = useIntegrationIdentity({
@@ -337,6 +334,12 @@ export default function AddMcpIntegration(props: { · 89 unmodified lines
337334 ...(slug ? { slug } : {}),
338335 ...(headers ? { headers } : {}),
339336 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+ : {}),
340343 },
341344 reactivityKeys: integrationWriteKeys,
342345 });
@@ -496,10 +499,14 @@ export default function AddMcpIntegration(props: { · 153 unmodified lines
496499 title="How does this server authenticate?"
497500 oauthMetadata="discovered"
498501 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."
500503 />
501504 )}
502505
506+ {probe && props.initialAuthNote ? (
507+ <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p>
508+ ) : null}
509+
503510 {/* Error (add server). Probe errors show inline on the field. */}
504511 {otherError && (
505512 <div className="space-y-2">
packages/plugins/mcp/src/react/auth-method-config.ts+53−0
@@ -9,6 +9,7 @@ · 8 unmodified lines
99
1010 import { AuthTemplateSlug } from "@executor-js/sdk/shared";
1111 import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
12+import type { AuthMethodSeed } from "@executor-js/react/components/auth-method-list-editor";
1213 import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
1314 import {
1415 authMethodFromSharedTemplate,
@@ -126,3 +127,55 @@ export function mcpAuthMethodInputsFromPlacements( · 111 unmodified lines
126127 if (wire.length === 0) return [];
127128 return [{ kind: "apikey", placements: wire }];
128129 }
130+
131+/** The auth methods a registry-driven MCP add declares before any editing:
132+ * the probe's detection plus the registry's declared facts. ONE policy for
133+ * both the add page (as editor seeds) and the one-click quick add (mapped
134+ * straight to wire inputs) — two copies drifted is how the picker and the
135+ * add page end up declaring different methods for the same server. */
136+export function mcpDetectedAuthSeeds(
137+ probe: {
138+ readonly requiresOAuth: boolean;
139+ readonly requiresAuthentication: boolean;
140+ } | null,
141+ registry: {
142+ readonly placement?: Placement | null;
143+ readonly kind?: string | undefined;
144+ },
145+): readonly AuthMethodSeed[] {
146+ const registryPlacement = registry.placement ?? null;
147+ if (!probe) {
148+ // No probe result (pending, or the server was unreachable from here).
149+ // The registry's declared facts still stand: an authless server or a
150+ // known header pattern seeds the list the probe would have produced.
151+ if (registryPlacement) return [{ value: { kind: "apikey", placements: [registryPlacement] } }];
152+ if (registry.kind === "none") return [{ value: { kind: "none" } }];
153+ return [];
154+ }
155+ if (probe.requiresOAuth) {
156+ const oauth: AuthMethodSeed = {
157+ value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] },
158+ label: "Detected",
159+ };
160+ // GitHub's MCP server takes a PAT bearer header in clients without
161+ // OAuth; when the registry declared that placement, offer it alongside.
162+ return registryPlacement
163+ ? [oauth, { value: { kind: "apikey", placements: [registryPlacement] } }]
164+ : [oauth];
165+ }
166+ if (probe.requiresAuthentication) {
167+ // The registry's exact placement beats the generic Bearer guess.
168+ return [
169+ {
170+ value: {
171+ kind: "apikey",
172+ placements: [
173+ registryPlacement ?? { carrier: "header", name: "Authorization", prefix: "Bearer " },
174+ ],
175+ },
176+ label: "Detected",
177+ },
178+ ];
179+ }
180+ return [{ value: { kind: "none" }, label: "Detected" }];
181+}
packages/plugins/mcp/src/react/integration-plugin.tsx+66−3
@@ -1,6 +1,21 @@
1−import { lazy, type ComponentProps, type ComponentType } from "react";
2−import type { IntegrationPlugin } from "@executor-js/sdk/client";
1+import { lazy, useCallback, type ComponentProps, type ComponentType } from "react";
2+import { useAtomSet } from "@effect/atom-react";
3+import * as Exit from "effect/Exit";
4+import type {
5+ IntegrationPlugin,
6+ IntegrationQuickAddInput,
7+ IntegrationQuickAddResult,
8+} from "@executor-js/sdk/client";
9+import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
10+import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";
11+import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
312 import { mcpPresets } from "../sdk/presets";
13+import { addMcpServer, probeMcpEndpoint } from "./atoms";
14+import {
15+ mcpAuthMethodInputFromEditorValue,
16+ mcpDetectedAuthSeeds,
17+ mcpWireAuthInput,
18+} from "./auth-method-config";
419
520 const importAdd = () => import("./AddMcpIntegration");
621 const importEditSheet = () => import("./EditMcpIntegration");
@@ -12,6 +27,51 @@ const LazyMcpAccountsPanel = lazy(importAccounts); · 5 unmodified lines
1227
1328 type AddProps = ComponentProps<IntegrationPlugin["add"]>;
1429
30+/** One-click add for a registry MCP row: probe, declare the auth methods the
31+ * add page would have seeded (same policy — `mcpDetectedAuthSeeds`), and
32+ * register. A failed probe means the configuration screen's job — it renders
33+ * the failure with retry UX — so quick add reports `{ok: false}` instead of
34+ * guessing. */
35+function useMcpQuickAdd(): (input: IntegrationQuickAddInput) => Promise<IntegrationQuickAddResult> {
36+ const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
37+ const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });
38+ return useCallback(
39+ async (input) => {
40+ const probeExit = await doProbe({ payload: { endpoint: input.url } });
41+ if (Exit.isFailure(probeExit)) return { ok: false, reason: "probe failed" };
42+ const probe = probeExit.value;
43+ const seeds = mcpDetectedAuthSeeds(probe, {
44+ placement: input.authHeader ? placementFromHeaderPattern(input.authHeader) : null,
45+ kind: input.authKind,
46+ });
47+ const methods = seeds.map((seed) =>
48+ mcpWireAuthInput(mcpAuthMethodInputFromEditorValue(seed.value)),
49+ );
50+ const slug = input.slug ? slugifyNamespace(input.slug) : undefined;
51+ const addExit = await doAddServer({
52+ payload: {
53+ transport: "remote" as const,
54+ name: input.name,
55+ endpoint: input.url,
56+ ...(slug ? { slug } : {}),
57+ ...(probe.instructions ? { description: probe.instructions } : {}),
58+ authenticationTemplate: methods.length > 0 ? methods : [{ kind: "none" as const }],
59+ // The probe reports when only legacy negotiation worked (a server
60+ // that echoes the modern revision but breaks its contract); pin it
61+ // so refreshes and tool calls use the same handshake.
62+ ...(probe.versionNegotiation === "legacy"
63+ ? { versionNegotiation: "legacy" as const }
64+ : {}),
65+ },
66+ reactivityKeys: integrationWriteKeys,
67+ });
68+ if (Exit.isFailure(addExit)) return { ok: false, reason: "add failed" };
69+ return { ok: true, slug: String(addExit.value.slug) };
70+ },
71+ [doProbe, doAddServer],
72+ );
73+}
74+
1575 export interface McpIntegrationPluginOptions {
1676 /**
1777 * Enable the stdio transport in the add-integration UI (tab + presets).
@@ -32,11 +92,13 @@ export const createMcpIntegrationPlugin = ( · 14 unmodified lines
3292 <LazyAddMcpIntegration {...props} allowStdio={allowStdio} />
3393 );
3494
35− const presets = allowStdio
95+ const base = allowStdio
3696 ? mcpPresets
3797 : mcpPresets.filter(
3898 (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"),
3999 );
100+ // Built-ins are registry-listed; a deployment's custom presets would not be.
101+ const presets = base.map((p) => ({ ...p, registryListed: true }));
40102
41103 return {
42104 key: "mcp",
@@ -50,6 +112,7 @@ export const createMcpIntegrationPlugin = ( · 7 unmodified lines
50112 void importEditSheet();
51113 void importAccounts();
52114 },
115+ useQuickAdd: useMcpQuickAdd,
53116 };
54117 };
55118
packages/plugins/mcp/src/sdk/connection.ts+6−5
@@ -634,13 +634,14 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 633 unmodified lines
634634
635635 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});
636636
637− // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable
638− // HTTP. SSE is a legacy-only transport; stdio negotiates per the
639− // integration's `versionNegotiation` (default legacy — see the stdio
640− // branch above).
637+ // Auto-negotiate the 2026-07-28 era on Streamable HTTP unless the config
638+ // pins `legacy` (for servers that echo the proposed revision and then
639+ // violate its contract). SSE is a legacy-only transport; stdio negotiates
640+ // per the integration's `versionNegotiation` (default legacy — see the
641+ // stdio branch above).
641642 const connectStreamableHttp = connectClient({
642643 transport: "streamable-http",
643− versionNegotiation: { mode: "auto" },
644+ ...(input.versionNegotiation === "legacy" ? {} : { versionNegotiation: { mode: "auto" } }),
644645 createTransport: (sdk) =>
645646 new sdk.client.StreamableHTTPClientTransport(endpoint, {
646647 requestInit,
packages/plugins/mcp/src/sdk/discover.ts+55−4
@@ -2,10 +2,11 @@ · 1 unmodified line
22 // MCP tool discovery — connect to an MCP server and list its tools
33 // ---------------------------------------------------------------------------
44
5−import { Duration, Effect, Option, Predicate } from "effect";
5+import { Duration, Effect, Option, Predicate, Schema } from "effect";
66
77 import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection";
88 import { McpToolDiscoveryError } from "./errors";
9+import { createMcpConnector, type ConnectorInput } from "./connection";
910 import { httpStatusFromCause } from "./http-status";
1011 import {
1112 decodeListToolsPage,
@@ -34,6 +35,14 @@ const DEFAULT_DISCOVER_TIMEOUT = Duration.seconds(15); · 22 unmodified lines
3435 // Public API
3536 // ---------------------------------------------------------------------------
3637
38+/** The SDK rejects with an Error subclass carrying a stable `code`; decode
39+ * the boundary instead of stringifying an unknown. */
40+const SdkFailure = Schema.Struct({
41+ code: Schema.optional(Schema.String),
42+ message: Schema.optional(Schema.String),
43+});
44+const decodeSdkFailure = Schema.decodeUnknownOption(SdkFailure);
45+
3746 /**
3847 * List every tool from an open MCP connection, following `nextCursor`
3948 * pagination (spec: `tools/list` is a paginated operation — a single call
@@ -68,14 +77,23 @@ const listAllTools = ( · 28 unmodified lines
6877 reauthorizationRequired: true,
6978 });
7079 }
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";
7186 const httpStatus = httpStatusFromCause(cause);
7287 return new McpToolDiscoveryError({
7388 stage: "list_tools",
7489 message:
75− httpStatus === undefined
76− ? "Failed listing MCP tools"
77− : `Failed listing MCP tools (HTTP ${httpStatus})`,
90+ failure?.message !== undefined
91+ ? `Failed listing MCP tools: ${failure.message.slice(0, 300)}`
92+ : httpStatus === undefined
93+ ? "Failed listing MCP tools"
94+ : `Failed listing MCP tools (HTTP ${httpStatus})`,
7895 ...(httpStatus === undefined ? {} : { httpStatus }),
96+ ...(modernContractViolation ? { modernContractViolation: true } : {}),
7997 });
8098 },
8199 });
@@ -103,6 +121,39 @@ const listAllTools = ( · 21 unmodified lines
103121 );
104122 });
105123
124+/** Discovery that survives version-echoing servers. Some servers answer the
125+ * proposed 2026-07-28 revision affirmatively while emitting 2024-era results
126+ * (Walmart's MCP), which the modern client rightly rejects. On that exact
127+ * signature, retry once with legacy negotiation and report which one worked,
128+ * so an add flow can pin `versionNegotiation: "legacy"` on the integration. */
129+export const discoverToolsFromInput = (
130+ input: ConnectorInput,
131+ timeoutMs?: number,
132+): Effect.Effect<
133+ { readonly manifest: McpToolManifest; readonly versionNegotiation: "auto" | "legacy" | null },
134+ McpToolDiscoveryError
135+> =>
136+ discoverTools(createMcpConnector(input), timeoutMs).pipe(
137+ Effect.map((manifest) => ({
138+ manifest,
139+ versionNegotiation: input.transport === "stdio" ? null : (input.versionNegotiation ?? "auto"),
140+ })),
141+ Effect.catch((error) => {
142+ const retriable =
143+ error.modernContractViolation === true &&
144+ input.transport !== "stdio" &&
145+ input.versionNegotiation !== "legacy";
146+ if (!retriable) return Effect.fail(error);
147+ return discoverTools(
148+ createMcpConnector({ ...input, versionNegotiation: "legacy" }),
149+ timeoutMs,
150+ ).pipe(
151+ Effect.map((manifest) => ({ manifest, versionNegotiation: "legacy" as const })),
152+ Effect.withSpan("mcp.discover.legacy_retry"),
153+ );
154+ }),
155+ );
156+
106157 /**
107158 * Connect to an MCP server and discover all available tools.
108159 * 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
4343 /** HTTP status from the underlying connect or tools/list failure, when
4444 * known. */
4545 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),
4651 /** The MCP OAuth provider reached the interactive authorization boundary.
4752 * Catalog callers use this structural signal to request reconnect without
4853 * parsing or exposing an upstream error message. */
packages/plugins/mcp/src/sdk/plugin.ts+27−19
@@ -45,7 +45,7 @@ import { · 44 unmodified lines
4545 import type { CodexPluginEntry } from "./codex-plugins";
4646 import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection";
4747 import { createMcpConnectionPool } from "./connection-pool";
48−import { discoverTools } from "./discover";
48+import { discoverToolsFromInput } from "./discover";
4949 import {
5050 McpConnectionError,
5151 type McpConnectionFailureKind,
@@ -218,6 +218,10 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 166 unmodified lines
218218 /** Single-method shorthand (legacy callers). Ignored when
219219 * `authenticationTemplate` is present. Defaults to none. */
220220 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),
221225 });
222226
223227 const McpStdioServerInputSchema = Schema.Struct({
@@ -305,6 +309,10 @@ const McpProbeEndpointOutputSchema = Schema.Struct({ · 81 unmodified lines
305309 /** The server's `instructions` from initialize — prefill for the add form's
306310 * description. Only available when the probe connected unauthenticated. */
307311 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),
308316 });
309317
310318 // ---------------------------------------------------------------------------
@@ -454,6 +462,7 @@ export const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfig · 143 unmodified lines
454462 authenticationTemplate: input.authenticationTemplate
455463 ? normalizeMcpAuthMethods(input.authenticationTemplate)
456464 : [mcpAuthMethodFromShorthand(input.auth ?? { kind: "none" })],
465+ versionNegotiation: input.versionNegotiation,
457466 };
458467 };
459468
@@ -695,6 +704,7 @@ const buildConnectorInput = ( · 235 unmodified lines
695704 authProvider,
696705 ...(authProvider === undefined ? {} : { staticOAuthBearer: true }),
697706 httpClientLayer,
707+ versionNegotiation: config.versionNegotiation,
698708 });
699709 };
700710
@@ -957,17 +967,17 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 256 unmodified lines
957967 const probeHeaders = typeof input === "string" ? undefined : input.headers;
958968 const probeQueryParams = typeof input === "string" ? undefined : input.queryParams;
959969
960− const connector = createMcpConnector({
970+ const result = yield* discoverToolsFromInput({
961971 transport: "remote",
962972 endpoint: trimmed,
963973 headers: probeHeaders,
964974 queryParams: probeQueryParams,
965975 httpClientLayer,
966− });
967−
968− const result = yield* discoverTools(connector).pipe(
969− Effect.map((m) => ({ ok: true as const, manifest: m })),
970− Effect.catch(() => Effect.succeed({ ok: false as const, manifest: null })),
976+ }).pipe(
977+ Effect.map((d) => ({ ok: true as const, ...d })),
978+ Effect.catch(() =>
979+ Effect.succeed({ ok: false as const, manifest: null, versionNegotiation: null }),
980+ ),
971981 Effect.withSpan("mcp.plugin.discover_tools"),
972982 );
973983
@@ -982,6 +992,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 8 unmodified lines
982992 toolCount: result.manifest.tools.length,
983993 serverName: result.manifest.server?.name ?? null,
984994 instructions: result.manifest.server?.instructions ?? null,
995+ ...(result.versionNegotiation === "legacy"
996+ ? { versionNegotiation: "legacy" as const }
997+ : {}),
985998 } satisfies McpProbeResult;
986999 }
9871000
@@ -1424,10 +1437,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 436 unmodified lines
14241437 template === null ? null : String(template),
14251438 allowStdio,
14261439 httpClientLayer,
1427− ).pipe(
1428− Effect.map((ci) => createMcpConnector(ci)),
1429− Effect.result,
1430− );
1440+ ).pipe(Effect.result);
14311441
14321442 if (Result.isFailure(built)) {
14331443 return {
@@ -1437,7 +1447,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 3 unmodified lines
14371447 };
14381448 }
14391449
1440− const discovered = yield* discoverTools(built.success).pipe(
1450+ const discovered = yield* discoverToolsFromInput(built.success).pipe(
14411451 Effect.result,
14421452 Effect.withSpan("mcp.plugin.discover_tools", {
14431453 attributes: { "mcp.connection.name": String(connection.name) },
@@ -1460,7 +1470,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 16 unmodified lines
14601470 : {}),
14611471 };
14621472 }
1463− return { tools: discovered.success.tools.map(toToolDef) };
1473+ return { tools: discovered.success.manifest.tools.map(toToolDef) };
14641474 }).pipe(
14651475 Effect.withSpan("mcp.plugin.resolve_tools", {
14661476 attributes: { "mcp.connection.name": String(connection.name) },
@@ -1676,13 +1686,11 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 209 unmodified lines
16761686 const name = parsed.value.hostname || "mcp";
16771687 const slug = deriveMcpNamespace({ endpoint: trimmed });
16781688
1679− const connector = createMcpConnector({
1689+ const connected = yield* discoverToolsFromInput({
16801690 transport: "remote",
16811691 endpoint: trimmed,
16821692 httpClientLayer,
1683− });
1684−
1685− const connected = yield* discoverTools(connector).pipe(
1693+ }).pipe(
16861694 Effect.map(() => true),
16871695 Effect.catch(() => Effect.succeed(false)),
16881696 Effect.withSpan("mcp.plugin.discover_tools"),
@@ -1794,9 +1802,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 105 unmodified lines
17941802 credential.template === null ? null : String(credential.template),
17951803 allowStdio,
17961804 options?.httpClientLayer ?? ctx.httpClientLayer,
1797− ).pipe(Effect.map((ci) => createMcpConnector(ci)));
1805+ );
17981806
1799− return yield* discoverTools(connector).pipe(
1807+ return yield* discoverToolsFromInput(connector).pipe(
18001808 Effect.map(
18011809 () =>
18021810 ({ 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
5757 }));
5858
5959 export const mcpPresets: readonly McpPreset[] = [
60− {
61− id: "emulate-mcp",
62− name: "Emulate MCP",
63− summary: "Deterministic MCP fixtures for validating native text and image content.",
64− url: "https://emulators.dev/mcp/query/mcp?token=demo-token",
65− endpoint: "https://emulators.dev/mcp/query/mcp?token=demo-token",
66− icon: "https://integrations.sh/logo/emulators.dev",
67− },
6860 {
6961 id: "deepwiki",
7062 name: "DeepWiki",
packages/plugins/mcp/src/sdk/types.ts+6−0
@@ -231,6 +231,12 @@ export const McpRemoteIntegrationConfig = Schema.Struct({ · 230 unmodified lines
231231 /** Declared auth methods — how a connection's values are rendered onto
232232 * requests. A connection's `template` picks one by slug. */
233233 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),
234240 });
235241 export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type;
236242
packages/plugins/openapi/src/api/group.ts+3−0
@@ -82,6 +82,9 @@ const AddSpecPayload = Schema.Struct({ · 81 unmodified lines
8282 name: Schema.optional(Schema.String),
8383 description: Schema.optional(Schema.String),
8484 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),
8588 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
8689 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
8790 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
6363 name: payload.name,
6464 description: payload.description,
6565 baseUrl: payload.baseUrl,
66+ displayDomain: payload.displayDomain,
6667 headers: payload.headers ? { ...payload.headers } : undefined,
6768 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
6869 specFormat: payload.specFormat,
packages/plugins/openapi/src/providers/google/discovery.test.ts+28−0
@@ -1,5 +1,6 @@
11 import { expect, it } from "@effect/vitest";
22 import { Effect, Option, Schema } from "effect";
3+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
34 import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";
45
56 import {
@@ -7,6 +8,7 @@ import { · 1 unmodified line
78 convertGoogleDiscoveryToOpenApi,
89 isGoogleDiscoveryUrl,
910 normalizeGoogleDiscoveryUrl,
11+ fetchGoogleDiscoveryDocument,
1012 } from "./discovery";
1113 import { googleOAuthConsentScopesForPreset } from "./service-policy";
1214 import { extract, parse } from "@executor-js/plugin-openapi";
@@ -1669,3 +1671,29 @@ it.effect("marks People API semantic inputs as required", () => · 1656 unmodified lines
16691671 });
16701672 }),
16711673 );
1674+
1675+it("falls back to the service-hosted Discovery URL when the central directory has no entry", async () => {
1676+ // Google Ads is not listed in the central directory: normalization maps it
1677+ // onto www.googleapis.com for identity, and that URL 404s. The fetch must
1678+ // still succeed by falling back to the host the caller named.
1679+ const seen: string[] = [];
1680+ const stub = HttpClient.make((request) => {
1681+ seen.push(request.url);
1682+ return Effect.succeed(
1683+ request.url.includes("www.googleapis.com")
1684+ ? HttpClientResponse.fromWeb(request, new Response("nope", { status: 404 }))
1685+ : HttpClientResponse.fromWeb(
1686+ request,
1687+ new Response('{"name":"googleads"}', { status: 200 }),
1688+ ),
1689+ );
1690+ });
1691+ const text = await Effect.runPromise(
1692+ fetchGoogleDiscoveryDocument(
1693+ "https://googleads.googleapis.com/$discovery/rest?version=v24",
1694+ ).pipe(Effect.provideService(HttpClient.HttpClient, stub)),
1695+ );
1696+ expect(text).toBe('{"name":"googleads"}');
1697+ expect(seen[0]).toContain("www.googleapis.com");
1698+ expect(seen[1]).toBe("https://googleads.googleapis.com/$discovery/rest?version=v24");
1699+});
packages/plugins/openapi/src/providers/google/discovery.ts+62−28
@@ -343,6 +343,25 @@ export const isGoogleDiscoveryUrl = (url: string): boolean => { · 342 unmodified lines
343343 return normalizeGoogleDiscoveryUrl(url) !== null;
344344 };
345345
346+/** The service's own Discovery endpoint, for a URL that named it. Normalization
347+ * canonicalizes most services onto the central directory, which is right for
348+ * identity but NOT universally fetchable: services outside the directory
349+ * (Google Ads) answer only on their own host. */
350+const serviceHostedDiscoveryUrl = (discoveryUrl: string): string | null => {
351+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() rejects malformed input
352+ try {
353+ const parsed = new URL(discoveryUrl.trim());
354+ const host = parsed.hostname.toLowerCase();
355+ if (host === "www.googleapis.com" || !host.endsWith(".googleapis.com")) return null;
356+ if (!["/$discovery/rest", "/$discovery/rest/"].includes(parsed.pathname)) return null;
357+ const version = parsed.searchParams.get("version")?.trim();
358+ if (!version || !DISCOVERY_VERSION_RE.test(version)) return null;
359+ return `https://${host}/$discovery/rest?version=${version}`;
360+ } catch {
361+ return null;
362+ }
363+};
364+
346365 export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(
347366 function* (discoveryUrl: string, credentials?: SpecFetchCredentials) {
348367 const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl);
@@ -353,35 +372,50 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov · 4 unmodified lines
353372 });
354373 }
355374 const client = yield* HttpClient.HttpClient;
356− const requestUrl = new URL(normalizedDiscoveryUrl);
357− for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {
358− requestUrl.searchParams.set(name, value);
359− }
360− let request = HttpClientRequest.get(requestUrl.toString()).pipe(
361− HttpClientRequest.setHeader("Accept", "application/json, */*"),
362− );
363− for (const [name, value] of Object.entries(credentials?.headers ?? {})) {
364− request = HttpClientRequest.setHeader(request, name, value);
365− }
366− const response = yield* client.execute(request).pipe(
367− Effect.mapError(
368− () =>
369− new OpenApiParseError({
370− message: "Failed to fetch Google Discovery document",
371− }),
372− ),
373− );
374− if (response.status < 200 || response.status >= 300) {
375− return yield* new OpenApiParseError({
376− message: `Failed to fetch Google Discovery document: HTTP ${response.status}`,
375+
376+ const attempt = (target: string) =>
377+ Effect.gen(function* () {
378+ const requestUrl = new URL(target);
379+ for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {
380+ requestUrl.searchParams.set(name, value);
381+ }
382+ let request = HttpClientRequest.get(requestUrl.toString()).pipe(
383+ HttpClientRequest.setHeader("Accept", "application/json, */*"),
384+ );
385+ for (const [name, value] of Object.entries(credentials?.headers ?? {})) {
386+ request = HttpClientRequest.setHeader(request, name, value);
387+ }
388+ const response = yield* client
389+ .execute(request)
390+ .pipe(
391+ Effect.mapError(
392+ () => new OpenApiParseError({ message: "Failed to fetch Google Discovery document" }),
393+ ),
394+ );
395+ if (response.status < 200 || response.status >= 300) {
396+ return yield* new OpenApiParseError({
397+ message: `Failed to fetch Google Discovery document: HTTP ${response.status}`,
398+ });
399+ }
400+ return yield* response.text.pipe(
401+ Effect.mapError(
402+ () =>
403+ new OpenApiParseError({ message: "Failed to read Google Discovery document body" }),
404+ ),
405+ );
377406 });
378− }
379− return yield* response.text.pipe(
380− Effect.mapError(
381− () =>
382− new OpenApiParseError({
383− message: "Failed to read Google Discovery document body",
384− }),
407+
408+ // Normalization maps a service-hosted URL onto the central directory for a
409+ // STABLE IDENTITY, but the directory does not list every service — Google
410+ // Ads answers only on googleads.googleapis.com, so the canonical form 404s
411+ // for a URL the user pasted that works. Fall back to the host they named
412+ // rather than maintaining an allowlist of every such service forever.
413+ const serviceHosted = serviceHostedDiscoveryUrl(discoveryUrl);
414+ return yield* attempt(normalizedDiscoveryUrl).pipe(
415+ Effect.catch((error) =>
416+ serviceHosted && serviceHosted !== normalizedDiscoveryUrl
417+ ? attempt(serviceHosted)
418+ : Effect.fail(error),
385419 ),
386420 );
387421 },
packages/plugins/openapi/src/providers/google/presets.ts+1−0
@@ -346,6 +346,7 @@ export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets · 345 unmodified lines
346346 ...(preset.featured ? { featured: preset.featured } : {}),
347347 family: "google",
348348 specFormat: "google-discovery",
349+ registryListed: true,
349350 defaultSlug: googleServiceSlug(preset.id),
350351 authTemplate: googleCatalogAuthTemplate(preset.id),
351352 ...(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
44 import { createExecutor, IntegrationSlug } from "@executor-js/sdk";
55 import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
66 import { openApiPlugin, parse } from "@executor-js/plugin-openapi";
7+import { resolveSpecFormatAdapter } from "../../sdk/spec-format";
78 import type { AuthenticationInput } from "@executor-js/plugin-openapi";
89
910 import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
@@ -290,3 +291,30 @@ it.effect("preserves a Google preset's full consumer consent boundary when refre · 280 unmodified lines
290291 );
291292 }),
292293 );
294+
295+it.effect("a hand-pasted Discovery URL selects the adapter without a preset", () =>
296+ Effect.gen(function* () {
297+ const detected = yield* resolveSpecFormatAdapter(
298+ [googleDiscoveryAdapter],
299+ undefined,
300+ GMAIL_URL,
301+ );
302+ expect(detected?.id).toBe("google-discovery");
303+
304+ const plainOpenApi = yield* resolveSpecFormatAdapter(
305+ [googleDiscoveryAdapter],
306+ undefined,
307+ "https://api.example.com/openapi.json",
308+ );
309+ expect(plainOpenApi).toBeNull();
310+
311+ // An explicit format id still wins over detection, and an unknown one
312+ // still fails rather than silently falling back.
313+ const explicit = yield* resolveSpecFormatAdapter(
314+ [googleDiscoveryAdapter],
315+ "google-discovery",
316+ "https://api.example.com/openapi.json",
317+ );
318+ expect(explicit?.id).toBe("google-discovery");
319+ }),
320+);
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
66 import {
77 convertGoogleDiscoveryBundleToOpenApi,
88 fetchGoogleDiscoveryDocument,
9+ isGoogleDiscoveryUrl,
910 normalizeGoogleDiscoveryUrl,
1011 } from "./discovery";
1112
@@ -47,6 +48,7 @@ export const deriveGoogleDiscoveryIdentity = (doc: unknown): DerivedIdentity | n · 35 unmodified lines
4748
4849 export const googleDiscoveryAdapter: SpecFormatAdapter = {
4950 id: "google-discovery",
51+ detectsUrl: isGoogleDiscoveryUrl,
5052 fetch: (input) =>
5153 Effect.gen(function* () {
5254 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
569569 ...(preset.featured ? { featured: preset.featured } : {}),
570570 family: "microsoft",
571571 specFormat: "microsoft-graph",
572+ registryListed: true,
572573 defaultSlug: microsoftServiceSlug(preset.id),
573574 authTemplate: microsoftGraphCatalogAuthTemplate(preset),
574575 ...(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
221221 ]);
222222 }),
223223 );
224+
225+it("detects Graph sources so a hand-pasted URL does not fall to the plain path", () => {
226+ const detects = (url: string) => microsoftGraphAdapter.detectsUrl?.(url) === true;
227+ // The monolith is the case that matters: on the plain path it is 43 MB and
228+ // trips the whole-document parse limit.
229+ expect(detects(`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=mail`)).toBe(true);
230+ expect(detects(MICROSOFT_GRAPH_OPENAPI_URL)).toBe(true);
231+ expect(
232+ detects(
233+ "https://github.com/UsefulSoftwareCo/executor/releases/download/graph-slices/mail.yaml",
234+ ),
235+ ).toBe(true);
236+ // Already-sliced hosted specs are self-contained and parse on the plain
237+ // path; claiming them would route them through the slice builder for nothing.
238+ expect(detects("https://integrations.sh/specs/microsoft-graph/mail.json")).toBe(false);
239+ expect(
240+ detects("https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json"),
241+ ).toBe(false);
242+});
packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts+21−0
@@ -3,6 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines
33 import type { SpecFormatAdapter } from "../../sdk/spec-format";
44
55 import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph";
6+import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets";
67 import { microsoftGraphPresetIdsForSliceAsset, microsoftGraphSliceAssetFromUrl } from "./slices";
78
89 const fragmentPresetIds = (hash: string): readonly string[] =>
@@ -40,8 +41,28 @@ const graphCatalogSelection = ( · 31 unmodified lines
4041 };
4142 };
4243
44+/** Recognizes the Graph sources this adapter exists to handle: Microsoft's
45+ * published monolith and executor's slice assets, with or without a
46+ * `#preset=` selector. Without this, pasting one of those URLs by hand falls
47+ * to the plain OpenAPI path, which then chokes on the 43 MB monolith — the
48+ * adapter only ever engaged when a preset supplied `specFormat`. */
49+const detectsGraphUrl = (url: string): boolean => {
50+ if (!URL.canParse(url)) return false;
51+ const parsed = new URL(url);
52+ parsed.hash = "";
53+ const bare = parsed.toString();
54+ return (
55+ bare === MICROSOFT_GRAPH_OPENAPI_URL ||
56+ microsoftGraphSliceAssetFromUrl(bare) !== null ||
57+ /^https:\/\/raw\.githubusercontent\.com\/microsoftgraph\/msgraph-metadata\/.*\/openapi\.ya?ml$/.test(
58+ bare,
59+ )
60+ );
61+};
62+
4363 export const microsoftGraphAdapter: SpecFormatAdapter = {
4464 id: "microsoft-graph",
65+ detectsUrl: detectsGraphUrl,
4566 fetch: (input) =>
4667 buildMicrosoftGraphOpenApiSpec(
4768 graphCatalogSelection(input.urls[0]),
packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx+44−5
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
2525 type AuthMethodRow,
2626 type AuthMethodSeed,
2727 } from "@executor-js/react/components/auth-method-list-editor";
28+import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
2829 import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";
2930 import { FieldLabel } from "@executor-js/react/components/field";
3031 import { FloatActions } from "@executor-js/react/components/float-actions";
@@ -170,18 +171,40 @@ export default function AddOpenApiIntegration(props: { · 139 unmodified lines
170171 initialUrl?: string;
171172 initialPreset?: string;
172173 initialNamespace?: string;
174+ initialAuthHeader?: string;
175+ initialAuthNote?: string;
176+ initialSpecOverrides?: string;
173177 }) {
174178 const integrationPlugins = useIntegrationPlugins();
175179 const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");
176180 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+ );
178191 const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null);
179192 const [analyzing, setAnalyzing] = useState(false);
180193 const [analyzeError, setAnalyzeError] = useState<string | null>(null);
181194
182195 const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl);
183196 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);
185208
186209 // After analysis
187210 const [preview, setPreview] = useState<SpecPreviewSummary | null>(null);
@@ -297,14 +320,26 @@ export default function AddOpenApiIntegration(props: { · 109 unmodified lines
297320 ...(preview?.headerPresets ?? []).map((preset) => preset.label),
298321 ...(preview?.oauth2Presets ?? []).map((preset) => preset.label),
299322 ];
300− return authenticationTemplate.map(
323+ const detected = authenticationTemplate.map(
301324 (template: Authentication, index: number): AuthMethodSeed => ({
302325 value: editorValueFromAuthentication(template),
303326 slug: String(template.slug),
304327 ...(labels[index] !== undefined ? { label: labels[index] } : {}),
305328 }),
306329 );
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]);
308343 const authMethodList = useAuthMethodList(authMethodSeeds);
309344
310345 // The methods to register, mapped back to stored `Authentication[]`. Drops
@@ -597,10 +632,14 @@ export default function AddOpenApiIntegration(props: { · 286 unmodified lines
597632 <AuthMethodListEditor
598633 list={authMethodList}
599634 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."
601636 />
602637 )}
603638
639+ {preview && props.initialAuthNote ? (
640+ <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p>
641+ ) : null}
642+
604643 {preview ? (
605644 <AddOpenApiHealthCheckSection
606645 presetHealthCheck={activePreset?.healthCheck}
packages/plugins/openapi/src/react/integration-plugin.test.ts+241−0
@@ -0,0 +1,241 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Option } from "effect";
3+
4+import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides";
5+import { Exit } from "effect";
6+
7+import {
8+ composeQuickAddAuth,
9+ performQuickAdd,
10+ quickAddRequestPayloads,
11+ quickAddSpecPlan,
12+ type QuickAddDeps,
13+} from "./integration-plugin";
14+
15+// The quick add's composition is where review found bugs twice: first it
16+// suppressed spec-declared methods, then it derived auth from the unmodified
17+// document while adding the overridden one. These pin the contract.
18+
19+const scopeOverride = decodeOpenApiSpecOverrides([
20+ {
21+ op: "replace",
22+ path: "/components/securitySchemes/OAuth2/flows/authorizationCode/scopes",
23+ value: { "files:read": "" },
24+ },
25+])!;
26+
27+describe("quickAddSpecPlan", () => {
28+ it("uses the same effective overrides for preview and add, preset first", () => {
29+ const registryOverrides = decodeOpenApiSpecOverrides([
30+ { op: "remove", path: "/components/securitySchemes/Cookie" },
31+ ]);
32+ const plan = quickAddSpecPlan(
33+ {
34+ id: "figma",
35+ name: "Figma",
36+ summary: "",
37+ specFormat: "plain-ish",
38+ specOverrides: [...scopeOverride],
39+ },
40+ registryOverrides,
41+ );
42+ // Preset overrides win over the registry's, mirroring the full add page.
43+ expect(plan.specOverrides).toEqual(scopeOverride);
44+ expect(plan.specFormat).toBe("plain-ish");
45+ });
46+
47+ it("an explicitly EMPTY preset override list suppresses the registry's", () => {
48+ // Presence-based, like the full page's `presetOverrides ?? registry`:
49+ // an empty list is a decision, not an absence.
50+ const registryOverrides = decodeOpenApiSpecOverrides([
51+ { op: "remove", path: "/components/securitySchemes/Cookie" },
52+ ]);
53+ const plan = quickAddSpecPlan(
54+ { id: "custom", name: "Custom", summary: "", specOverrides: [] },
55+ registryOverrides,
56+ );
57+ expect(plan.specOverrides).toBeUndefined();
58+ });
59+
60+ it("falls through to registry overrides when the preset has none", () => {
61+ const registryOverrides = decodeOpenApiSpecOverrides([
62+ { op: "remove", path: "/components/securitySchemes/Cookie" },
63+ ]);
64+ const plan = quickAddSpecPlan(undefined, registryOverrides);
65+ expect(plan.specOverrides).toEqual(registryOverrides);
66+ expect(plan.specFormat).toBeUndefined();
67+ });
68+});
69+
70+describe("composeQuickAddAuth", () => {
71+ const registryPlacement = {
72+ carrier: "header",
73+ name: "Authorization",
74+ prefix: "Bearer ",
75+ } as const;
76+
77+ it("keeps override-derived OAuth scopes and appends the registry key", () => {
78+ // The preview reflects the OVERRIDDEN document (quickAddSpecPlan sends
79+ // the same overrides to preview): its oauth preset carries the replaced
80+ // scopes. The composed template must retain them verbatim and add the
81+ // registry header only because no key method was detected.
82+ const template = composeQuickAddAuth([], registryPlacement, {
83+ headerPresets: [],
84+ oauth2Presets: [
85+ {
86+ label: "OAuth2",
87+ securitySchemeName: "OAuth2",
88+ flow: "authorizationCode" as const,
89+ authorizationUrl: Option.some("https://example.com/authorize"),
90+ tokenUrl: "https://example.com/token",
91+ resource: Option.none(),
92+ refreshUrl: Option.none(),
93+ // The overridden document's scopes — the whole point: the preview
94+ // reflects the SAME effective spec the add stores.
95+ scopes: { "files:read": "" },
96+ identityScopes: [] as const,
97+ },
98+ ],
99+ servers: [{ url: "https://api.example.com" }],
100+ });
101+ expect(template).toHaveLength(2);
102+ const oauth = template.find((method) => "kind" in method && method.kind === "oauth2");
103+ expect(oauth && "scopes" in oauth ? oauth.scopes : undefined).toEqual(["files:read"]);
104+ const key = template.find((method) => !("kind" in method) || method.kind !== "oauth2");
105+ expect(JSON.stringify(key)).toContain("Authorization");
106+ });
107+
108+ it("does not append the registry key when the spec already declares one", () => {
109+ const template = composeQuickAddAuth([], registryPlacement, {
110+ headerPresets: [
111+ {
112+ label: "API key",
113+ headers: { "X-API-Key": null },
114+ secretHeaders: ["X-API-Key"],
115+ secretQueryParams: [],
116+ },
117+ ],
118+ oauth2Presets: [],
119+ servers: [{ url: "https://api.example.com" }],
120+ });
121+ expect(template).toHaveLength(1);
122+ expect(JSON.stringify(template[0])).toContain("X-API-Key");
123+ });
124+});
125+
126+describe("quickAddRequestPayloads", () => {
127+ it("the preview and the add carry the identical spec plan", () => {
128+ // The regression contract itself: reverting either call site to a bare
129+ // payload makes these fields diverge and this test fail.
130+ const plan = quickAddSpecPlan(
131+ {
132+ id: "figma",
133+ name: "Figma",
134+ summary: "",
135+ specFormat: "fmt",
136+ specOverrides: [...scopeOverride],
137+ },
138+ undefined,
139+ );
140+ const requests = quickAddRequestPayloads(
141+ { url: "https://example.com/spec.yaml", name: "Figma API", domain: "figma.com" },
142+ "figma_api",
143+ plan,
144+ );
145+ expect(requests.preview.specFormat).toBe("fmt");
146+ expect(requests.preview.specOverrides).toEqual(scopeOverride);
147+ expect(requests.add.specFormat).toBe(requests.preview.specFormat);
148+ expect(requests.add.specOverrides).toEqual(requests.preview.specOverrides);
149+ expect(requests.add.displayDomain).toBe("figma.com");
150+ expect(requests.add.spec).toEqual({ kind: "url", url: "https://example.com/spec.yaml" });
151+ });
152+});
153+
154+describe("performQuickAdd", () => {
155+ // The OPERATION under test, with the two mutations captured — the guard
156+ // the review demanded twice: helper tests kept passing while a call site
157+ // could quietly stop using the shared plan.
158+ it("sends the identical spec plan to preview and add, and composed auth", async () => {
159+ const previews: unknown[] = [];
160+ const adds: unknown[] = [];
161+ const deps: QuickAddDeps = {
162+ presets: [
163+ {
164+ id: "figma",
165+ name: "Figma",
166+ summary: "",
167+ url: "https://example.com/spec.yaml",
168+ specFormat: "fmt",
169+ specOverrides: [...scopeOverride],
170+ },
171+ ],
172+ preview: (payload) => {
173+ previews.push(payload);
174+ return Promise.resolve(
175+ Exit.succeed({
176+ headerPresets: [],
177+ oauth2Presets: [
178+ {
179+ label: "OAuth2",
180+ securitySchemeName: "OAuth2",
181+ flow: "authorizationCode" as const,
182+ authorizationUrl: Option.some("https://example.com/authorize"),
183+ tokenUrl: "https://example.com/token",
184+ resource: Option.none(),
185+ refreshUrl: Option.none(),
186+ scopes: { "files:read": "" },
187+ identityScopes: [] as const,
188+ },
189+ ],
190+ servers: [{ url: "https://api.example.com" }],
191+ }),
192+ );
193+ },
194+ add: (payload) => {
195+ adds.push(payload);
196+ return Promise.resolve(Exit.succeed({ slug: "figma_api" }));
197+ },
198+ };
199+ const result = await performQuickAdd(deps, {
200+ url: "https://example.com/spec.yaml",
201+ name: "Figma API",
202+ slug: "figma-api",
203+ domain: "figma.com",
204+ authHeader: "Authorization: Bearer {token}",
205+ });
206+ expect(result).toEqual({ ok: true, slug: "figma_api" });
207+ expect(previews).toHaveLength(1);
208+ expect(adds).toHaveLength(1);
209+ const preview = previews[0] as Record<string, unknown>;
210+ const add = adds[0] as Record<string, unknown>;
211+ // The regression contract: BOTH real payloads carry the one spec plan.
212+ expect(preview.specFormat).toBe("fmt");
213+ expect(preview.specOverrides).toEqual(scopeOverride);
214+ expect(add.specFormat).toBe(preview.specFormat);
215+ expect(add.specOverrides).toEqual(preview.specOverrides);
216+ expect(add.slug).toBe("figma_api");
217+ expect(add.displayDomain).toBe("figma.com");
218+ // Auth derived from the overridden document, registry key appended.
219+ const template = add.authenticationTemplate as readonly unknown[];
220+ expect(JSON.stringify(template)).toContain("files:read");
221+ expect(JSON.stringify(template)).toContain("Authorization");
222+ });
223+
224+ it("skips the preview entirely when the registry declared no header", async () => {
225+ const previews: unknown[] = [];
226+ const deps: QuickAddDeps = {
227+ presets: [],
228+ preview: (payload) => {
229+ previews.push(payload);
230+ return Promise.resolve(Exit.fail("unexpected"));
231+ },
232+ add: () => Promise.resolve(Exit.succeed({ slug: "plain" })),
233+ };
234+ const result = await performQuickAdd(deps, {
235+ url: "https://example.com/openapi.json",
236+ name: "Plain API",
237+ });
238+ expect(result).toEqual({ ok: true, slug: "plain" });
239+ expect(previews).toHaveLength(0);
240+ });
241+});
packages/plugins/openapi/src/react/integration-plugin.ts+252−17
@@ -1,6 +1,234 @@
1−import { lazy } from "react";
2−import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client";
1+import { lazy, useCallback } from "react";
2+import { useAtomSet } from "@effect/atom-react";
3+import * as Exit from "effect/Exit";
4+import type {
5+ IntegrationPlugin,
6+ IntegrationPreset,
7+ IntegrationQuickAddInput,
8+ IntegrationQuickAddResult,
9+} from "@executor-js/sdk/client";
10+import { AuthTemplateSlug } from "@executor-js/sdk/shared";
11+import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";
12+import { placementFromHeaderPattern, type Placement } from "@executor-js/react/lib/auth-placements";
13+import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
314 import { openApiPresets } from "../sdk/presets";
15+import { detectedAuthenticationTemplates } from "../sdk/derive-auth";
16+import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
17+import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config";
18+import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides";
19+
20+const normalizedSpecUrl = (url: string): string => {
21+ if (!URL.canParse(url)) return url.trim().replace(/\/$/, "");
22+ const parsed = new URL(url);
23+ parsed.hash = "";
24+ parsed.searchParams.sort();
25+ return parsed.toString().replace(/\/$/, "");
26+};
27+
28+/** The preset table still knows things no spec can say — GitHub's OAuth
29+ * endpoints against a spec that declares NO security at all. A registry row
30+ * whose URL is a preset's URL gets that knowledge pulled across. Matches the
31+ * plugin's COMPLETE preset list, so a deployment's custom presets
32+ * contribute too, not just the built-ins. */
33+const presetForSpecUrl = (
34+ presets: readonly IntegrationPreset[],
35+ url: string,
36+): IntegrationPreset | undefined => {
37+ const target = normalizedSpecUrl(url);
38+ return presets.find(
39+ (preset) => preset.url !== undefined && normalizedSpecUrl(preset.url) === target,
40+ );
41+};
42+
43+/** One-click add for a registry OpenAPI row. The spec itself is the
44+ * configuration: omitting `authenticationTemplate` and `baseUrl` tells the
45+ * server to derive both from the document, exactly what the add page's
46+ * untouched defaults submit. Registry spec overrides ride along. */
47+
48+/** The spec inputs a quick add must hold CONSISTENT between preview and add:
49+ * deriving auth from a document other than the one being added is how a
50+ * preset's scope overrides got silently dropped. Preset overrides win over
51+ * the registry's, mirroring the full add page. */
52+export const quickAddSpecPlan = (
53+ preset: IntegrationPreset | undefined,
54+ registryOverrides: ReturnType<typeof decodeOpenApiSpecOverrides>,
55+): {
56+ readonly specFormat?: string;
57+ readonly specOverrides?: NonNullable<ReturnType<typeof decodeOpenApiSpecOverrides>>;
58+} => {
59+ const presetOverrides = preset?.specOverrides
60+ ? decodeOpenApiSpecOverrides(preset.specOverrides)
61+ : undefined;
62+ // PRESENCE-based, mirroring the full page's `presetOverrides ??
63+ // registryOverrides`: a preset declaring an explicitly EMPTY override list
64+ // is a decision — suppress the registry's patches — not an absence.
65+ const effective = presetOverrides !== undefined ? presetOverrides : registryOverrides;
66+ return {
67+ ...(preset?.specFormat ? { specFormat: preset.specFormat } : {}),
68+ ...(effective && effective.length > 0 ? { specOverrides: effective } : {}),
69+ };
70+};
71+
72+/** The preview and add requests built from ONE plan, so they cannot diverge:
73+ * deriving auth from a different effective document than the one stored is
74+ * the bug this module has now had twice. The add payload takes further
75+ * fields (family, health check, auth template) AFTER this base. */
76+export const quickAddRequestPayloads = (
77+ input: { readonly url: string; readonly name: string; readonly domain?: string },
78+ slug: string,
79+ specPlan: ReturnType<typeof quickAddSpecPlan>,
80+): {
81+ readonly preview: { readonly spec: string } & ReturnType<typeof quickAddSpecPlan>;
82+ readonly add: {
83+ readonly spec: { readonly kind: "url"; readonly url: string };
84+ readonly slug: string;
85+ readonly name: string;
86+ readonly displayDomain?: string;
87+ } & ReturnType<typeof quickAddSpecPlan>;
88+} => ({
89+ preview: { spec: input.url, ...specPlan },
90+ add: {
91+ spec: { kind: "url", url: input.url },
92+ slug,
93+ name: input.name,
94+ ...(input.domain ? { displayDomain: input.domain } : {}),
95+ ...specPlan,
96+ },
97+});
98+
99+/** The full add page's method policy as one pure step: preset OAuth wins
100+ * outright; else every preview-detected method is preserved and the
101+ * registry's key placement is appended only when the detected set has no
102+ * key method (GitHub declares no security at all). `preview` MUST be the
103+ * summary of the same effective document the add will store. */
104+export const composeQuickAddAuth = (
105+ presetMethods: readonly ReturnType<typeof openApiWireAuthInput>[],
106+ registryPlacement: Placement | null,
107+ preview: {
108+ readonly headerPresets: Parameters<typeof detectedAuthenticationTemplates>[0];
109+ readonly oauth2Presets: Parameters<typeof detectedAuthenticationTemplates>[1];
110+ readonly servers: readonly { readonly url: string }[];
111+ } | null,
112+): readonly ReturnType<typeof openApiWireAuthInput>[] => {
113+ if (presetMethods.length > 0) {
114+ return registryPlacement
115+ ? [...presetMethods, openApiWireAuthInput(templateFromPlacements([registryPlacement]))]
116+ : presetMethods;
117+ }
118+ if (!registryPlacement || preview === null) return presetMethods;
119+ const detected = detectedAuthenticationTemplates(
120+ preview.headerPresets,
121+ preview.oauth2Presets,
122+ preview.servers[0]?.url ?? "",
123+ );
124+ const detectedHasApiKey = detected.some((template) => template.kind === "apikey");
125+ return [
126+ ...detected.map(openApiWireAuthInput),
127+ ...(detectedHasApiKey
128+ ? []
129+ : [openApiWireAuthInput(templateFromPlacements([registryPlacement]))]),
130+ ];
131+};
132+
133+export interface QuickAddDeps {
134+ readonly presets: readonly IntegrationPreset[];
135+ /** The two mutations, injected so the OPERATION is testable against its
136+ * actual outgoing payloads — helper-level tests kept passing while a call
137+ * site quietly stopped using the shared plan. */
138+ readonly preview: (
139+ payload: ReturnType<typeof quickAddRequestPayloads>["preview"],
140+ ) => Promise<Exit.Exit<NonNullable<Parameters<typeof composeQuickAddAuth>[2]>, unknown>>;
141+ readonly add: (
142+ payload: ReturnType<typeof quickAddRequestPayloads>["add"] & {
143+ readonly family?: string;
144+ readonly healthCheck?: IntegrationPreset["healthCheck"];
145+ readonly authenticationTemplate?: ReturnType<typeof openApiWireAuthInput>[];
146+ },
147+ ) => Promise<Exit.Exit<{ readonly slug: string }, unknown>>;
148+}
149+
150+/** The complete quick-add operation, pure of React: everything between the
151+ * picker's click and the two API calls. */
152+export const performQuickAdd = async (
153+ deps: QuickAddDeps,
154+ input: IntegrationQuickAddInput,
155+): Promise<IntegrationQuickAddResult> => {
156+ const { presets } = deps;
157+ const slug = slugifyNamespace(input.slug ?? input.name);
158+ if (!slug) return { ok: false, reason: "no derivable slug" };
159+ // Registry overrides arrive as untyped JSON; a malformed patch goes to
160+ // the configuration screen (its editor renders the parse error) rather
161+ // than being silently dropped from a "successful" quick add.
162+ const specOverrides = input.specOverrides
163+ ? decodeOpenApiSpecOverrides(input.specOverrides)
164+ : undefined;
165+ if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) {
166+ return { ok: false, reason: "unparseable spec overrides" };
167+ }
168+ const preset = presetForSpecUrl(presets, input.url);
169+ const registryPlacement = input.authHeader ? placementFromHeaderPattern(input.authHeader) : null;
170+ const presetMethods = (preset?.authTemplate ?? []).flatMap((template) =>
171+ template.kind === "oauth2"
172+ ? [
173+ openApiWireAuthInput({
174+ ...template,
175+ slug: AuthTemplateSlug.make(template.slug),
176+ resource: template.resource ?? undefined,
177+ }),
178+ ]
179+ : [],
180+ );
181+ // ONE spec plan for both preview and add: deriving auth from a
182+ // different effective document than the one stored is how a preset's
183+ // scope overrides got silently dropped.
184+ const specPlan = quickAddSpecPlan(preset, specOverrides);
185+ const requests = quickAddRequestPayloads(
186+ { url: input.url, name: input.name, ...(input.domain ? { domain: input.domain } : {}) },
187+ slug,
188+ specPlan,
189+ );
190+ let preview: Parameters<typeof composeQuickAddAuth>[2] = null;
191+ if (presetMethods.length === 0 && registryPlacement) {
192+ // Composing with spec knowledge needs the spec: one preview call,
193+ // only on this path (a plain registry row with no auth facts still
194+ // adds with zero extra round trips).
195+ const previewExit = await deps.preview(requests.preview);
196+ if (Exit.isFailure(previewExit)) return { ok: false, reason: "preview failed" };
197+ preview = previewExit.value;
198+ }
199+ const authenticationTemplate = composeQuickAddAuth(presetMethods, registryPlacement, preview);
200+ const exit = await deps.add({
201+ ...requests.add,
202+ ...(preset?.family ? { family: preset.family } : {}),
203+ ...(preset?.healthCheck ? { healthCheck: preset.healthCheck } : {}),
204+ ...(authenticationTemplate.length > 0
205+ ? { authenticationTemplate: [...authenticationTemplate] }
206+ : {}),
207+ });
208+ if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" };
209+ return { ok: true, slug: String(exit.value.slug) };
210+};
211+
212+function makeUseQuickAdd(presets: readonly IntegrationPreset[]) {
213+ return function useOpenApiQuickAdd(): (
214+ input: IntegrationQuickAddInput,
215+ ) => Promise<IntegrationQuickAddResult> {
216+ const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" });
217+ const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
218+ return useCallback(
219+ (input) =>
220+ performQuickAdd(
221+ {
222+ presets,
223+ preview: (payload) => doPreview({ payload }),
224+ add: (payload) => doAdd({ payload, reactivityKeys: integrationWriteKeys }),
225+ },
226+ input,
227+ ),
228+ [doAdd, doPreview],
229+ );
230+ };
231+}
4232
5233 const importAdd = () => import("./AddOpenApiIntegration");
6234 const importEditSheet = () => import("./UpdateSpecSection");
@@ -10,20 +238,27 @@ export interface OpenApiClientConfig { · 3 unmodified lines
10238 readonly presets?: readonly IntegrationPreset[];
11239 }
12240
13−export const createOpenApiIntegrationPlugin = (
14− config?: OpenApiClientConfig,
15−): IntegrationPlugin => ({
16− key: "openapi",
17− label: "OpenAPI",
18− add: lazy(importAdd),
19− editSheet: lazy(importEditSheet),
20− accounts: lazy(importAccounts),
21− presets: [...openApiPresets, ...(config?.presets ?? [])],
22− preload: () => {
23− void importAdd();
24− void importEditSheet();
25− void importAccounts();
26− },
27−});
241+export const createOpenApiIntegrationPlugin = (config?: OpenApiClientConfig): IntegrationPlugin => {
242+ // Built-ins are registry-listed (the picker shows the registry's card);
243+ // a deployment's custom presets are not, and keep their own cards.
244+ const presets: readonly IntegrationPreset[] = [
245+ ...openApiPresets.map((preset) => ({ ...preset, registryListed: true })),
246+ ...(config?.presets ?? []),
247+ ];
248+ return {
249+ key: "openapi",
250+ label: "OpenAPI",
251+ add: lazy(importAdd),
252+ editSheet: lazy(importEditSheet),
253+ accounts: lazy(importAccounts),
254+ presets,
255+ preload: () => {
256+ void importAdd();
257+ void importEditSheet();
258+ void importAccounts();
259+ },
260+ useQuickAdd: makeUseQuickAdd(presets),
261+ };
262+};
28263
29264 export 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
5252 specUrl: Schema.optional(Schema.String),
5353 /** Optional base URL override. */
5454 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),
5560 /** Static headers applied to every request (no secret material). */
5661 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
5762 /** 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
7171 ).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]);
7272 });
7373 });
74+
75+describe("query-located api keys", () => {
76+ it("a query apiKey preset places the secret in the query string", () => {
77+ const templates = detectedAuthenticationTemplates(
78+ [
79+ {
80+ label: "Legacy-API-key (query)",
81+ headers: {},
82+ secretHeaders: [],
83+ secretQueryParams: ["apiKey"],
84+ },
85+ ],
86+ [],
87+ "https://example.com",
88+ );
89+ expect(templates).toHaveLength(1);
90+ const template = templates[0];
91+ expect(template?.kind).toBe("apikey");
92+ expect(template?.kind === "apikey" ? template.placements : []).toEqual([
93+ { carrier: "query", name: "apiKey" },
94+ ]);
95+ });
96+
97+ it("a strategy mixing a header and a query param asks for two inputs", () => {
98+ const templates = detectedAuthenticationTemplates(
99+ [
100+ {
101+ label: "exp-api-key + apiKey (query)",
102+ headers: { "exp-api-key": null },
103+ secretHeaders: ["exp-api-key"],
104+ secretQueryParams: ["apiKey"],
105+ },
106+ ],
107+ [],
108+ "https://example.com",
109+ );
110+ const template = templates[0];
111+ const placements = template?.kind === "apikey" ? template.placements : [];
112+ expect(placements.map((placement) => placement.carrier)).toEqual(["header", "query"]);
113+ expect(new Set(placements.map((placement) => placement.variable)).size).toBe(2);
114+ });
115+});
packages/plugins/openapi/src/sdk/derive-auth.ts+24−11
@@ -104,20 +104,33 @@ const apiKeyTemplateFromHeaderPreset = ( · 103 unmodified lines
104104 preset: HeaderPreset,
105105 slug: AuthTemplateSlug,
106106 ): 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]);
108111 return {
109112 slug,
110113 kind: "apikey",
111− placements: preset.secretHeaders.map((headerName) => {
112− const prefix = headerPrefix(preset, headerName);
113− const variable = variables.get(headerName);
114− return {
115− carrier: "header" as const,
116− name: headerName,
117− ...(prefix ? { prefix } : {}),
118− ...(variable ? { variable } : {}),
119− };
120− }),
114+ placements: [
115+ ...preset.secretHeaders.map((headerName) => {
116+ const prefix = headerPrefix(preset, headerName);
117+ const variable = variables.get(headerName);
118+ return {
119+ carrier: "header" as const,
120+ name: headerName,
121+ ...(prefix ? { prefix } : {}),
122+ ...(variable ? { variable } : {}),
123+ };
124+ }),
125+ ...secretQueryParams.map((paramName) => {
126+ const variable = variables.get(paramName);
127+ return {
128+ carrier: "query" as const,
129+ name: paramName,
130+ ...(variable ? { variable } : {}),
131+ };
132+ }),
133+ ],
121134 };
122135 };
123136
packages/plugins/openapi/src/sdk/plugin.ts+11−1
@@ -98,6 +98,10 @@ export interface OpenApiSpecConfig { · 97 unmodified lines
9898 * then the title). */
9999 readonly description?: string;
100100 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;
101105 /** Static headers applied to every request (no secret material). */
102106 readonly headers?: Record<string, string>;
103107 /** Static query params applied to every request. */
@@ -307,6 +311,7 @@ const AddIntegrationInputSchema = Schema.Struct({ · 203 unmodified lines
307311 name: Schema.optional(Schema.String),
308312 description: Schema.optional(Schema.String),
309313 baseUrl: Schema.optional(Schema.String),
314+ displayDomain: Schema.optional(Schema.String),
310315 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
311316 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
312317 specFormat: Schema.optional(Schema.String),
@@ -603,7 +608,9 @@ export const describeOpenApiIntegrationDisplay = ( · 290 unmodified lines
603608 ): { readonly url?: string; readonly family?: string } => {
604609 const config = decodeOpenApiIntegrationConfig(record.config);
605610 return {
606− url: config?.baseUrl ?? config?.specUrl,
611+ url:
612+ config?.baseUrl ??
613+ (config?.displayDomain ? `https://${config.displayDomain}` : config?.specUrl),
607614 ...(config?.family ? { family: config.family } : {}),
608615 };
609616 };
@@ -661,6 +668,7 @@ export const openApiPlugin = definePlugin< · 51 unmodified lines
661668 const adapter = yield* resolveSpecFormatAdapter(
662669 options?.specFormats ?? [],
663670 config.specFormat,
671+ config.spec.kind === "url" ? config.spec.url : undefined,
664672 );
665673 if (adapter) {
666674 if (config.spec.kind !== "url") {
@@ -782,6 +790,7 @@ export const openApiPlugin = definePlugin< · 115 unmodified lines
782790 const adapter = yield* resolveSpecFormatAdapter(
783791 options?.specFormats ?? [],
784792 config.specFormat,
793+ config.spec.kind === "url" ? config.spec.url : undefined,
785794 );
786795 const derivedIdentity =
787796 adapter?.deriveIdentity && resolved.document
@@ -865,6 +874,7 @@ export const openApiPlugin = definePlugin< · 77 unmodified lines
865874 // resolved per call from the operation's `servers` (extracted from
866875 // the spec), so we never bake a derived base URL into the config.
867876 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
877+ ...(config.displayDomain ? { displayDomain: config.displayDomain } : {}),
868878 ...(config.headers ? { headers: config.headers } : {}),
869879 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
870880 ...(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
145145 id: "axiom",
146146 name: "Axiom",
147147 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",
149151 icon: "https://integrations.sh/logo/axiom.co",
150152 },
151153 {
@@ -169,13 +171,6 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 17 unmodified lines
169171 url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml",
170172 icon: "https://integrations.sh/logo/digitalocean.com",
171173 },
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− },
179174 {
180175 id: "val-town",
181176 name: "Val Town",
packages/plugins/openapi/src/sdk/preview.ts+17−10
@@ -122,6 +122,9 @@ export const HeaderPreset = Schema.Struct({ · 121 unmodified lines
122122 headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)),
123123 /** Which headers should be stored as secrets */
124124 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)),
125128 });
126129 export type HeaderPreset = typeof HeaderPreset.Type;
127130
@@ -347,6 +350,7 @@ const buildHeaderPresets = ( · 219 unmodified lines
347350
348351 const headers: Record<string, string | null> = {};
349352 const secretHeaders: string[] = [];
353+ const secretQueryParams: string[] = [];
350354 const labelParts: string[] = [];
351355
352356 for (const scheme of resolved) {
@@ -363,8 +367,16 @@ const buildHeaderPresets = ( · 10 unmodified lines
363367 headers[headerName] = null;
364368 secretHeaders.push(headerName);
365369 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)`);
366373 } 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;
368380 } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
369381 return [];
370382 } else {
@@ -372,21 +384,16 @@ const buildHeaderPresets = ( · 1 unmodified line
372384 }
373385 }
374386
375− if (Object.keys(headers).length === 0 && resolved.length > 0) {
376− return [
377− HeaderPreset.make({
378− label: labelParts.join(" + "),
379− headers: {},
380− secretHeaders: [],
381− }),
382− ];
383− }
387+ // A strategy in which nothing is renderable (cookie-only, or an exotic
388+ // scheme type) yields no preset rather than an empty one.
389+ if (secretHeaders.length === 0 && secretQueryParams.length === 0) return [];
384390
385391 return [
386392 HeaderPreset.make({
387393 label: labelParts.join(" + "),
388394 headers,
389395 secretHeaders,
396+ ...(secretQueryParams.length > 0 ? { secretQueryParams } : {}),
390397 }),
391398 ];
392399 });
packages/plugins/openapi/src/sdk/spec-format.ts+12−1
@@ -41,13 +41,24 @@ export interface SpecFormatAdapter { · 40 unmodified lines
4141 readonly id: string;
4242 readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>;
4343 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;
4449 }
4550
4651 export const resolveSpecFormatAdapter = (
4752 adapters: readonly SpecFormatAdapter[],
4853 id: string | undefined,
54+ url?: string,
4955 ): 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+ }
5162 const adapter = adapters.find((candidate) => candidate.id === id);
5263 if (adapter) return Effect.succeed(adapter);
5364 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
1313
1414 describe("analytics seam", () => {
1515 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();
1717 });
1818
1919 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
3131
3232 export interface AnalyticsEvents {
3333 // ── 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" };
3538 integration_detect_submitted: {
3639 success: boolean;
3740 detected_kind?: string;
3841 confidence?: string;
3942 };
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+ };
4050 integration_add_started: {
4151 plugin_key: string;
4252 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
2727 checkConnectionHealth,
2828 connectionsAllAtom,
2929 createOAuthClientOptimistic,
30+ integrationAtom,
3031 integrationHealthCheckAtom,
3132 integrationHealthCheckCandidatesAtom,
3233 oauthClientsOptimisticAtom,
@@ -85,6 +86,7 @@ import { · 52 unmodified lines
8586 } from "./oauth-client-form";
8687 import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog";
8788 import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";
89+import { CredentialGuidancePanel } from "./credential-guidance";
8890 import { PlacementLine, type AuthMethod } from "../lib/auth-placements";
8991 import { connectionIdentifier } from "../lib/connection-name";
9092 import { Badge } from "./badge";
@@ -625,6 +627,14 @@ export const connectionExistsMessage = (label: string): string => · 534 unmodified lines
625627 * explicit choice. Personal: a connection is most often a personal credential. */
626628 export const DEFAULT_CONNECTION_OWNER: Owner = "user";
627629
630+/** The method the modal opens on. OAuth needs a registered app (or a DCR
631+ * round-trip) before "Connect" does anything; a key is one paste. When an
632+ * integration declares both, starting on OAuth greets most users with
633+ * "Register app" — a dead end — while the working method sits one tab over.
634+ * Prefer the first non-OAuth method; OAuth stays one click away. */
635+export const preferredMethodId = (methods: readonly AuthMethod[]): string =>
636+ (methods.find((method) => method.kind !== "oauth") ?? methods[0])?.id ?? "";
637+
628638 const authMethodKey = (method: AuthMethod): string =>
629639 method.source === "custom" ? `custom:${String(method.template)}` : `declared:${method.id}`;
630640
@@ -1423,7 +1433,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 792 unmodified lines
14231433 );
14241434 const [addingMethod, setAddingMethod] = useState(false);
14251435
1426− const [methodId, setMethodId] = useState<string>(methods[0]?.id ?? "");
1436+ const [methodId, setMethodId] = useState<string>(preferredMethodId(methods));
14271437 // One value per distinct credential input (`variable → pasted value`). A
14281438 // single-secret method has just `{ token }`; a method with two distinct inputs
14291439 // (e.g. Datadog's two keys) collects one value per variable.
@@ -1501,6 +1511,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 71 unmodified lines
15011511 // The integration's declared health check + its candidate operations. When a
15021512 // check is configured we probe against it; when not, the user picks one of the
15031513 // 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;
15041520 const healthCheckResult = useAtomValue(integrationHealthCheckAtom(integration));
15051521 const configuredHealthCheck = AsyncResult.isSuccess(healthCheckResult)
15061522 ? healthCheckResult.value
@@ -1605,7 +1621,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 98 unmodified lines
16051621 m.id === initialState.template || String(m.template) === initialState.template,
16061622 )
16071623 : undefined;
1608− setMethodId(initialMethod?.id ?? allMethods[0]!.id);
1624+ setMethodId(initialMethod?.id ?? preferredMethodId(allMethods));
16091625 }, [allMethods, initialState?.template, methodId]);
16101626
16111627 // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link.
@@ -2894,6 +2910,15 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1282 unmodified lines
28942910 <div className="space-y-2">
28952911 <StepHeader index={1} label={authStepLabel} />
28962912
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+
28972922 {isOAuth && method ? (
28982923 cimdActive ? (
28992924 <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
142142 <div className="flex items-center justify-between">
143143 <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
144144 {detected ? <LockIcon className="size-3 shrink-0" aria-hidden /> : null}
145− <span>
146− Method {index + 1}
147− {row.seedLabel ? ` · ${row.seedLabel}` : ""}
148− </span>
145+ {/* Named by what it is. "Method 1" told the reader nothing
146+ except that there might be a Method 2, and next to a
147+ masked value it read as a locked credential field —
148+ people tried to paste their key into it. */}
149+ <span>{detected ? detectedMethodTitle(row) : `Method ${index + 1}`}</span>
149150 </span>
150151 <Button
151152 type="button"
@@ -181,6 +182,14 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) { · 29 unmodified lines
181182 );
182183 }
183184
185+/** What a spec-detected method should be called: the credential it wants, plus
186+ * the spec's own name for it when there is more than one to tell apart. */
187+function detectedMethodTitle(row: AuthMethodRow): string {
188+ const kind = row.value.kind;
189+ const base = kind === "oauth" ? "OAuth" : kind === "apikey" ? "API key" : "No authentication";
190+ return row.seedLabel ? `${base} · ${row.seedLabel}` : base;
191+}
192+
184193 /** One read-only `label value` line, mono value, for the detected summary. */
185194 function SpecField(props: { readonly label: string; readonly value: string }) {
186195 return (
@@ -204,16 +213,11 @@ function DetectedMethodSummary(props: { · 17 unmodified lines
204213 const { value, oauthMetadata } = props;
205214 // Name the auth kind explicitly: a detection label like MCP's "Detected"
206215 // 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";
209216 return (
210217 <div className="space-y-2">
211− <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">
212− {kindLabel}
213− </p>
214218 <div
215219 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"
217221 >
218222 {value.kind === "none" && (
219223 <p className="text-xs">No credential — tools are callable without an account.</p>
@@ -221,10 +225,13 @@ function DetectedMethodSummary(props: { · 1 unmodified line
221225
222226 {value.kind === "apikey" &&
223227 (value.placements.length > 0 ? (
224− <div className="flex flex-wrap gap-x-4 gap-y-1">
225− {value.placements.map((placement, i: number) => (
226− <PlacementLine key={i} placement={placement} />
227− ))}
228+ <div className="space-y-1">
229+ <p className="text-xs">This API takes a key, sent as:</p>
230+ <div className="flex flex-wrap gap-x-4 gap-y-1">
231+ {value.placements.map((placement, i: number) => (
232+ <PlacementLine key={i} placement={placement} />
233+ ))}
234+ </div>
228235 </div>
229236 ) : null)}
230237
@@ -246,7 +253,18 @@ function DetectedMethodSummary(props: { · 15 unmodified lines
246253 ))}
247254 </div>
248255
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>
250268 </div>
251269 );
252270 }
packages/react/src/components/credential-guidance.tsx+168−0
@@ -0,0 +1,168 @@
1+import { useMemo, useState } from "react";
2+import { ArrowUpRightIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
3+import { getDomain } from "tldts";
4+
5+import { trackEvent } from "../api/analytics";
6+import { useCredentialGuidance } from "../lib/integrations-sh-catalog";
7+import { cn } from "../lib/utils";
8+
9+// ---------------------------------------------------------------------------
10+// What this key is, and where to get one.
11+//
12+// The registry records, per provider, what the credential is called, the page
13+// that mints it, and the provider's own setup steps. Asking someone for a key
14+// without any of that is the reason the connect form produced "Okay so what
15+// type of key do I need?" — the answer existed, one fetch away, and was thrown
16+// out on arrival.
17+//
18+// Strictly supplementary: the form works unchanged when the registry knows
19+// nothing, and this renders nothing at all rather than an empty frame.
20+// ---------------------------------------------------------------------------
21+
22+/** The registry's setup text is markdown. Links, bold and inline code are all
23+ * that occur in practice, so render those three rather than pull a markdown
24+ * pipeline into a form. */
25+function SetupText(props: { readonly text: string }) {
26+ const nodes = useMemo(
27+ () =>
28+ props.text
29+ .split(/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`)/g)
30+ .filter((chunk) => chunk.length > 0)
31+ .map((chunk, index) => {
32+ const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(chunk);
33+ if (link) {
34+ return (
35+ <a
36+ key={index}
37+ href={link[2]}
38+ target="_blank"
39+ rel="noreferrer"
40+ className="font-medium text-foreground underline underline-offset-2"
41+ >
42+ {link[1]}
43+ </a>
44+ );
45+ }
46+ const bold = /^\*\*([^*]+)\*\*$/.exec(chunk);
47+ if (bold) {
48+ return (
49+ <span key={index} className="font-medium text-foreground">
50+ {bold[1]}
51+ </span>
52+ );
53+ }
54+ const code = /^`([^`]+)`$/.exec(chunk);
55+ if (code) {
56+ return (
57+ <code key={index} className="font-mono text-[0.9em] text-foreground">
58+ {code[1]}
59+ </code>
60+ );
61+ }
62+ return <span key={index}>{chunk}</span>;
63+ }),
64+ [props.text],
65+ );
66+ return <p className="text-xs leading-relaxed text-muted-foreground">{nodes}</p>;
67+}
68+
69+export function CredentialGuidancePanel(props: {
70+ /** The integration's display URL; the domain is read from it. */
71+ readonly displayUrl: string | undefined;
72+ /** "oauth" hides key guidance — there is no key to fetch. */
73+ readonly methodKind: string | undefined;
74+}) {
75+ const domain = useMemo(
76+ () => (props.displayUrl ? getDomain(props.displayUrl) : null),
77+ [props.displayUrl],
78+ );
79+ const guidance = useCredentialGuidance(domain);
80+
81+ const relevant = useMemo(() => {
82+ if (props.methodKind === "oauth") {
83+ return guidance.find((entry) => entry.type.startsWith("oauth"));
84+ }
85+ // Anything that is not an OAuth grant is the key the form is asking for.
86+ return guidance.find((entry) => !entry.type.startsWith("oauth")) ?? guidance[0];
87+ }, [guidance, props.methodKind]);
88+
89+ if (!relevant || (!relevant.generateUrl && !relevant.setup)) return null;
90+
91+ return (
92+ <div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 px-3 py-2.5">
93+ <div className="flex items-baseline justify-between gap-3">
94+ <span className="text-xs font-medium text-foreground">{relevant.label}</span>
95+ {relevant.generateUrl ? (
96+ <a
97+ href={relevant.generateUrl}
98+ target="_blank"
99+ rel="noreferrer"
100+ className="flex shrink-0 items-center gap-1 text-xs font-medium text-foreground underline underline-offset-2"
101+ >
102+ Get a key
103+ <ArrowUpRightIcon className="size-3" aria-hidden />
104+ </a>
105+ ) : null}
106+ </div>
107+ {relevant.setup ? <SetupText text={relevant.setup} /> : null}
108+ {/* Provenance, stated plainly: this text is machine-written registry
109+ data, not the provider's own docs — the reader should weight it
110+ accordingly (menus move, prefixes change). The thumbs are the
111+ accuracy loop: votes land in analytics keyed by domain, so wrong
112+ guidance is findable instead of silently misleading. */}
113+ <div className="flex items-center justify-between gap-3 pt-0.5">
114+ <p className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground/60">
115+ AI-generated guidance via{" "}
116+ <a
117+ href={`https://integrations.sh/${domain ?? ""}`}
118+ target="_blank"
119+ rel="noreferrer"
120+ className="underline underline-offset-2 hover:text-muted-foreground"
121+ >
122+ integrations.sh
123+ </a>
124+ </p>
125+ <GuidanceVote domain={domain ?? ""} label={relevant.label} />
126+ </div>
127+ </div>
128+ );
129+}
130+
131+/** One vote per render of the panel; the pair collapses to the chosen thumb
132+ * so a second opinion means reopening, not flip-flopping the metric. */
133+function GuidanceVote(props: { readonly domain: string; readonly label: string }) {
134+ const [voted, setVoted] = useState<"up" | "down" | null>(null);
135+ const vote = (value: "up" | "down") => {
136+ if (voted) return;
137+ setVoted(value);
138+ trackEvent("credential_guidance_rated", {
139+ domain: props.domain,
140+ credential_label: props.label,
141+ vote: value,
142+ });
143+ };
144+ return (
145+ <span className="flex shrink-0 items-center gap-1">
146+ {(["up", "down"] as const).map((value) => {
147+ const Icon = value === "up" ? ThumbsUpIcon : ThumbsDownIcon;
148+ if (voted && voted !== value) return null;
149+ return (
150+ // oxlint-disable-next-line react/forbid-elements -- an icon chip, not a Button variant
151+ <button
152+ key={value}
153+ type="button"
154+ aria-label={value === "up" ? "Guidance was accurate" : "Guidance was wrong"}
155+ disabled={voted !== null}
156+ onClick={() => vote(value)}
157+ className={cn(
158+ "rounded p-1 text-muted-foreground/50 transition-colors",
159+ voted === value ? "text-foreground" : "hover:bg-muted hover:text-muted-foreground",
160+ )}
161+ >
162+ <Icon className="size-3" aria-hidden />
163+ </button>
164+ );
165+ })}
166+ </span>
167+ );
168+}
packages/react/src/components/integration-favicon.tsx+0−1
@@ -209,7 +209,6 @@ export function IntegrationFavicon({ · 208 unmodified lines
209209 alt=""
210210 width={size}
211211 height={size}
212− loading="lazy"
213212 onError={() =>
214213 setFailedSrcs((current) =>
215214 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
3737 "/connect/$integrationSlug",
3838 "/integrations/$namespace",
3939 "/integrations/add/$pluginKey",
40+ "/integrations/browse",
4041 "/policies",
4142 "/secrets",
4243 "/tools",
@@ -78,6 +79,7 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 35 unmodified lines
7879 "/integrations/add/$pluginKey",
7980 route("/integrations/add/$pluginKey", file("integrations.add.$pluginKey.tsx")),
8081 ],
82+ ["/integrations/browse", route("/integrations/browse", file("integrations.browse.tsx"))],
8183 ["/policies", route("/policies", file("policies.tsx"))],
8284 ["/secrets", route("/secrets", file("secrets.tsx"))],
8385 ["/tools", route("/tools", file("tools.tsx"))],
packages/react/src/lib/auth-placements.test.ts+23−1
@@ -1,7 +1,7 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import type { AuthMethodDescriptor } from "@executor-js/sdk/shared";
33
4−import { authMethodsFromDescriptors } from "./auth-placements";
4+import { authMethodsFromDescriptors, placementFromHeaderPattern } from "./auth-placements";
55
66 describe("authMethodsFromDescriptors", () => {
77 it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => {
@@ -101,3 +101,25 @@ describe("authMethodsFromDescriptors", () => { · 93 unmodified lines
101101 expect(authMethodsFromDescriptors([])).toEqual([]);
102102 });
103103 });
104+
105+describe("placementFromHeaderPattern", () => {
106+ it("keeps a Bearer prefix, space included", () => {
107+ expect(placementFromHeaderPattern("Authorization: Bearer {token}")).toEqual({
108+ carrier: "header",
109+ name: "Authorization",
110+ prefix: "Bearer ",
111+ });
112+ });
113+
114+ it("an empty prefix is meaningful: Linear's keys take no Bearer", () => {
115+ expect(placementFromHeaderPattern("Authorization: {api_key}")).toEqual({
116+ carrier: "header",
117+ name: "Authorization",
118+ prefix: "",
119+ });
120+ });
121+
122+ it("rejects text that is not a header pattern", () => {
123+ expect(placementFromHeaderPattern("just some prose")).toBeNull();
124+ });
125+});
packages/react/src/lib/auth-placements.tsx+16−0
@@ -40,6 +40,22 @@ export interface Placement { · 39 unmodified lines
4040 /** A fresh, empty header placement — the default first row in an editor. */
4141 export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", prefix: "" });
4242
43+/** Parses a registry header pattern — `"Authorization: Bearer {token}"` — into
44+ * a placement. The literal text between the colon and the `{variable}` is the
45+ * prefix, and its ABSENCE is load-bearing: `"Authorization: {api_key}"` is how
46+ * the registry says Linear's personal keys take no Bearer prefix. */
47+export function placementFromHeaderPattern(pattern: string): Placement | null {
48+ const match = /^([A-Za-z0-9-]+):\s*(.*)$/.exec(pattern.trim());
49+ if (!match) return null;
50+ const rest = match[2] ?? "";
51+ const brace = rest.indexOf("{");
52+ return {
53+ carrier: "header",
54+ name: match[1] ?? "",
55+ prefix: brace >= 0 ? rest.slice(0, brace) : "",
56+ };
57+}
58+
4359 /** What an auth method is, presentationally. `kind` drives the credential UI:
4460 * `oauth` shows a Connect button, `none` creates a connection with no
4561 * 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
99 // GraphQL endpoint).
1010 // ---------------------------------------------------------------------------
1111
12−import { useEffect, useRef, useState } from "react";
12+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1313 import * as Data from "effect/Data";
1414 import * as Effect from "effect/Effect";
1515 import * as Exit from "effect/Exit";
@@ -28,10 +28,49 @@ export type CatalogKind = (typeof CONNECTABLE_KINDS)[number]; · 12 unmodified lines
2828 const isConnectableKind = (kind: string): kind is CatalogKind =>
2929 (CONNECTABLE_KINDS as readonly string[]).includes(kind);
3030
31+/** One connectable surface of a domain, as the registry reports it.
32+ *
33+ * `slug` is the registry's stable identifier for THIS surface, and it is the
34+ * namespace the add flow seeds — which makes it the honest answer to "have I
35+ * added this already?". The domain is not: a vendor's surfaces can live on
36+ * other hosts entirely (GitHub's MCP server is on api.githubcopilot.com), and
37+ * one domain routinely offers several surfaces that are separate
38+ * integrations. */
39+export interface CatalogSurface {
40+ readonly kind: CatalogKind;
41+ readonly slug: string;
42+ /** What to point the add flow at. Absent when the registry has no
43+ * machine-readable locator, in which case the surface document still has to
44+ * be fetched on click. */
45+ readonly url?: string;
46+ /** A hand-picked product mark (Google Calendar's own logo rather than the
47+ * generic G), present only on curated surfaces. */
48+ readonly icon?: string;
49+ /** How to authenticate, for surfaces whose connect target cannot describe
50+ * it itself — a GraphQL endpoint has no spec document, so the registry is
51+ * the only carrier of facts like Linear's no-Bearer-prefix header. */
52+ readonly auth?: {
53+ readonly kind?: string;
54+ /** Header pattern, e.g. "Authorization: Bearer {token}". */
55+ readonly header?: string;
56+ readonly note?: string;
57+ };
58+ /** RFC 6902 JSON Patch the registry says to apply to the fetched spec —
59+ * how a vendor's published document gets improved without hosting a fork
60+ * (e.g. Neon's console session cookies posing as security schemes). */
61+ readonly specOverrides?: readonly unknown[];
62+}
63+
3164 export interface CatalogSearchEntry {
3265 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;
3369 readonly description: string;
3470 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[];
3574 }
3675
3776 export const catalogLogoUrl = (domain: string, size: number): string =>
@@ -44,7 +83,14 @@ class CatalogRequestError extends Data.TaggedError("CatalogRequestError")<{ · 6 unmodified lines
4483 const fetchCatalogJson = (url: URL): Effect.Effect<unknown, CatalogRequestError> =>
4584 Effect.gen(function* () {
4685 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" }),
4894 catch: () => new CatalogRequestError({ message: `Failed to reach ${url.host}.` }),
4995 });
5096 if (!response.ok) {
@@ -66,34 +112,103 @@ const SearchResponse = Schema.Struct({ · 15 unmodified lines
66112 results: Schema.Array(
67113 Schema.Struct({
68114 domain: Schema.String,
115+ name: Schema.optional(Schema.String),
69116 description: Schema.String,
70117 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+ ),
71136 }),
72137 ),
73138 });
74139 const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse);
75140
141+export interface CatalogSearchPage {
142+ readonly entries: readonly CatalogSearchEntry[];
143+ /** Rows the registry returned BEFORE the connectable-kind filter. Paging
144+ * exhaustion must key on this: a page can be full at the registry and
145+ * shrink here (CLI-only rows are dropped), and judging "no more pages" by
146+ * the filtered length ended the infinite scroll mid-catalog. */
147+ readonly rawCount: number;
148+}
149+
150+export const parseCatalogSearchPage = (payload: unknown): CatalogSearchPage => ({
151+ entries: parseCatalogSearch(payload),
152+ rawCount: Option.match(decodeSearchResponse(payload), {
153+ onNone: () => 0,
154+ onSome: ({ results }) => results.length,
155+ }),
156+});
157+
76158 export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntry[] =>
77159 Option.match(decodeSearchResponse(payload), {
78160 onNone: () => [],
79161 onSome: ({ results }) =>
80162 results
81− .map((entry) => ({
82− domain: entry.domain,
83− description: entry.description,
84− kinds: entry.kinds.filter(isConnectableKind),
85− }))
163+ .map((entry) => {
164+ const surfaces = (entry.surfaces ?? []).flatMap((surface): readonly CatalogSurface[] =>
165+ isConnectableKind(surface.kind)
166+ ? [
167+ {
168+ kind: surface.kind,
169+ slug: surface.slug,
170+ ...(surface.url ? { url: surface.url } : {}),
171+ ...(surface.icon ? { icon: surface.icon } : {}),
172+ ...(surface.auth ? { auth: surface.auth } : {}),
173+ ...(surface.specOverrides && surface.specOverrides.length > 0
174+ ? { specOverrides: surface.specOverrides }
175+ : {}),
176+ },
177+ ]
178+ : [],
179+ );
180+ return {
181+ domain: entry.domain,
182+ ...(entry.name && entry.name !== entry.domain ? { name: entry.name } : {}),
183+ description: entry.description,
184+ kinds: entry.kinds.filter(isConnectableKind),
185+ ...(surfaces.length > 0 ? { surfaces } : {}),
186+ };
187+ })
86188 .filter((entry) => entry.kinds.length > 0),
87189 });
88190
191+export interface CatalogQuery {
192+ /** Free text. EMPTY IS MEANINGFUL: the registry answers an empty query with
193+ * its popularity-ordered head, which is what a browse view wants. */
194+ readonly query: string;
195+ /** Restrict to one surface kind. Omitted means every connectable kind. */
196+ readonly kind?: CatalogKind;
197+ /** The registry caps this at 100. */
198+ readonly limit?: number;
199+ /** Ranked results to skip — how the picker pages an endless list. */
200+ readonly offset?: number;
201+}
202+
89203 export const searchCatalog = (
90− query: string,
91− limit = 10,
92−): Effect.Effect<readonly CatalogSearchEntry[], CatalogRequestError> => {
204+ input: CatalogQuery,
205+): Effect.Effect<CatalogSearchPage, CatalogRequestError> => {
93206 const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN);
94− url.searchParams.set("q", query);
95− url.searchParams.set("limit", String(limit));
96− return Effect.map(fetchCatalogJson(url), parseCatalogSearch);
207+ url.searchParams.set("q", input.query);
208+ url.searchParams.set("limit", String(input.limit ?? 10));
209+ if (input.kind) url.searchParams.set("kind", input.kind);
210+ if (input.offset) url.searchParams.set("offset", String(input.offset));
211+ return Effect.map(fetchCatalogJson(url), parseCatalogSearchPage);
97212 };
98213
99214 // ---------------------------------------------------------------------------
@@ -135,18 +250,35 @@ const connectUrlOf = (surface: SurfaceRecord, kind: CatalogKind): string | undef · 35 unmodified lines
135250 ? surface.url
136251 : undefined;
137252
253+const normalizedProductToken = (value: string): string =>
254+ value.toLowerCase().replace(/[^a-z0-9]/g, "");
255+
138256 export const pickConnectTarget = (
139257 payload: unknown,
140258 kind: CatalogKind,
259+ preferredName?: string,
141260 ): CatalogConnectTarget | undefined =>
142261 Option.match(decodeSurfaceDocument(payload), {
143262 onNone: () => undefined,
144263 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) => {
146269 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;
148280 }
149− return undefined;
281+ return usable[0];
150282 },
151283 });
152284
@@ -155,11 +287,12 @@ export const pickConnectTarget = ( · 2 unmodified lines
155287 export const resolveConnectTarget = (
156288 domain: string,
157289 kinds: readonly CatalogKind[],
290+ preferredName?: string,
158291 ): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => {
159292 const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN);
160293 return Effect.map(fetchCatalogJson(url), (payload) => {
161294 for (const kind of kinds) {
162− const target = pickConnectTarget(payload, kind);
295+ const target = pickConnectTarget(payload, kind, preferredName);
163296 if (target) return target;
164297 }
165298 return undefined;
@@ -172,16 +305,27 @@ export const resolveConnectTarget = ( · 6 unmodified lines
172305
173306 /** Domains already represented by a loaded plugin's presets, so the catalog
174307 * section doesn't repeat what the preset list above it already shows. */
308+/** The domain a preset represents, read from its integrations.sh logo URL
309+ * first (which names the domain outright) and its spec/endpoint URL second. */
310+export const presetDomain = (preset: {
311+ readonly icon?: string;
312+ readonly url?: string;
313+}): string | null => {
314+ for (const candidate of [preset.icon, preset.url]) {
315+ if (!candidate) continue;
316+ const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate);
317+ const domain = logoMatch?.[1] ?? getDomain(candidate);
318+ if (domain) return domain;
319+ }
320+ return null;
321+};
322+
175323 export const presetDomains = (plugins: readonly IntegrationPlugin[]): ReadonlySet<string> => {
176324 const domains = new Set<string>();
177325 for (const plugin of plugins) {
178326 for (const preset of plugin.presets ?? []) {
179− for (const candidate of [preset.icon, preset.url]) {
180− if (!candidate) continue;
181− const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate);
182− const domain = logoMatch?.[1] ?? getDomain(candidate);
183− if (domain) domains.add(domain);
184− }
327+ const domain = presetDomain(preset);
328+ if (domain) domains.add(domain);
185329 }
186330 }
187331 return domains;
@@ -206,6 +350,9 @@ export const filterCatalogEntries = ( · 18 unmodified lines
206350 .map((entry) => ({
207351 ...entry,
208352 kinds: entry.kinds.filter((kind) => opts.availableKinds.includes(kind)),
353+ ...(entry.surfaces
354+ ? { surfaces: entry.surfaces.filter((s) => opts.availableKinds.includes(s.kind)) }
355+ : {}),
209356 }))
210357 .filter((entry) => entry.kinds.length > 0);
211358
@@ -213,43 +360,293 @@ export const filterCatalogEntries = ( · 1 unmodified line
213360 // Hook — debounced search with an in-session response cache
214361 // ---------------------------------------------------------------------------
215362
216−const SEARCH_DEBOUNCE_MS = 250;
363+const SEARCH_DEBOUNCE_MS = 150;
364+/** Free-text queries wait for a second character; a browse (empty query) does
365+ * not, because there is nothing to narrow. */
217366 const MIN_QUERY_LENGTH = 2;
218−const searchCache = new Map<string, readonly CatalogSearchEntry[]>();
367+const BROWSE_LIMIT = 60;
219368
220369 export interface CatalogSearchState {
221370 readonly entries: readonly CatalogSearchEntry[];
222371 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;
223385 }
224386
225−export function useCatalogSearch(rawQuery: string): CatalogSearchState {
226− const query = rawQuery.trim().toLowerCase();
227− const [state, setState] = useState<CatalogSearchState>({ entries: [], loading: false });
387+const cacheKey = (input: CatalogQuery): string =>
388+ `${input.kind ?? "all"}:${input.limit ?? 0}:${input.query}`;
389+/** Responses are cached for as long as the registry says they are fresh, and
390+ * no longer. The first version of this cache had no expiry at all, so a
391+ * session that searched once kept that answer for its whole life — a registry
392+ * correction could ship and the console would keep showing the old catalog
393+ * until the tab was closed. */
394+const SEARCH_TTL_MS = 60_000;
395+const searchCache = new Map<
396+ string,
397+ { readonly at: number; readonly entries: readonly CatalogSearchEntry[] }
398+>();
399+
400+/**
401+ * Browse or search the public registry.
402+ *
403+ * One hook for both because they are the same request with a different `q`:
404+ * an empty query returns the popularity-ordered head, a non-empty one filters
405+ * it. Search stays server-side and edge-cached, so no view ever downloads the
406+ * multi-thousand-entry catalog.
407+ */
408+export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState {
409+ const query = input.query.trim().toLowerCase();
410+ const kind = input.kind;
411+ const limit = input.limit ?? BROWSE_LIMIT;
412+ const requestKeyOf = (forQuery: string) =>
413+ cacheKey({ query: forQuery, limit, ...(kind ? { kind } : {}) });
414+ const requestKey = requestKeyOf(query);
415+ const [state, setState] = useState<{
416+ readonly entries: readonly CatalogSearchEntry[];
417+ /** The request the entries belong to. Held-over results from a previous
418+ * query are DELIBERATE (no empty flash between keystrokes), but they
419+ * must be knowable as stale — rendering them unmarked is how a
420+ * "calendar" search showed Gmail cards. */
421+ readonly forKey: string;
422+ readonly loading: boolean;
423+ readonly failed: boolean;
424+ }>({ entries: [], forKey: requestKey, loading: true, failed: false });
228425 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]);
229443
230444 useEffect(() => {
231445 const requestId = ++generation.current;
232− if (query.length < MIN_QUERY_LENGTH) {
233− setState({ entries: [], loading: false });
446+ // A partial word is not yet a query; leave the previous results in place
447+ // rather than flashing an empty list between keystrokes.
448+ if (query.length > 0 && query.length < MIN_QUERY_LENGTH) {
449+ // Held entries keep their own forKey, so the hook reports them stale.
450+ setState((previous) => ({ ...previous, loading: false }));
234451 return;
235452 }
236− const cached = searchCache.get(query);
237− if (cached) {
238− setState({ entries: cached, loading: false });
453+ const request: CatalogQuery = { query, limit, ...(kind ? { kind } : {}) };
454+ const cached = searchCache.get(cacheKey(request));
455+ if (cached && Date.now() - cached.at < SEARCH_TTL_MS) {
456+ setState({
457+ entries: cached.entries,
458+ forKey: cacheKey(request),
459+ loading: false,
460+ failed: false,
461+ });
239462 return;
240463 }
241464 setState((previous) => ({ ...previous, loading: true }));
242− const timer = setTimeout(() => {
243− void Effect.runPromiseExit(searchCatalog(query)).then((exit) => {
244− if (Exit.isSuccess(exit)) searchCache.set(query, exit.value);
245− if (generation.current !== requestId) return;
246− // Reachability is best-effort — the presets and URL detection above
247− // the catalog section keep working without it.
248− setState({ entries: Exit.isSuccess(exit) ? exit.value : [], loading: false });
249− });
250− }, SEARCH_DEBOUNCE_MS);
465+ const timer = setTimeout(
466+ () => {
467+ void Effect.runPromiseExit(searchCatalog(request)).then((exit) => {
468+ if (Exit.isSuccess(exit)) {
469+ searchCache.set(cacheKey(request), { at: Date.now(), entries: exit.value.entries });
470+ }
471+ if (generation.current !== requestId) return;
472+ setState({
473+ entries: Exit.isSuccess(exit) ? exit.value.entries : [],
474+ forKey: cacheKey(request),
475+ loading: false,
476+ failed: Exit.isFailure(exit),
477+ });
478+ });
479+ },
480+ // The first browse should not sit behind a debounce meant for typing.
481+ query.length === 0 ? 0 : SEARCH_DEBOUNCE_MS,
482+ );
251483 return () => clearTimeout(timer);
252− }, [query]);
484+ }, [query, kind, limit]);
485+
486+ const extras = more.key === requestKey ? more : null;
487+ const baseCount = state.entries.length;
488+ const hasMore =
489+ !state.loading &&
490+ !state.failed &&
491+ baseCount >= limit &&
492+ !(extras?.exhausted ?? false) &&
493+ !(query.length > 0 && query.length < MIN_QUERY_LENGTH);
494+
495+ // SYNCHRONOUS in-flight guard. A fast scroll fires the sentinel several
496+ // times before React commits `loadingMore`, and every call then saw the
497+ // stale false, fetched the SAME offset in parallel, appended the duplicate
498+ // page, and pushed the next offset past rows that were never shown. A ref
499+ // is settled the instant the first call claims it.
500+ const pageInFlight = useRef(false);
501+
502+ const loadMore = useCallback(() => {
503+ if (!hasMore || pageInFlight.current) return;
504+ pageInFlight.current = true;
505+ setMore((previous) =>
506+ previous.key === requestKey
507+ ? { ...previous, loadingMore: true }
508+ : { key: requestKey, entries: [], rawConsumed: 0, loadingMore: true, exhausted: false },
509+ );
510+ // The first page consumed `limit` raw rows (were it short, hasMore would
511+ // already be false); extra pages report their raw size.
512+ const request: CatalogQuery = {
513+ query,
514+ limit,
515+ offset: limit + (extras?.rawConsumed ?? 0),
516+ ...(kind ? { kind } : {}),
517+ };
518+ void Effect.runPromiseExit(searchCatalog(request)).then((exit) => {
519+ pageInFlight.current = false;
520+ setMore((previous) => {
521+ if (previous.key !== requestKey) return previous;
522+ const page = Exit.isSuccess(exit) ? exit.value : { entries: [], rawCount: 0 };
523+ return {
524+ key: requestKey,
525+ entries: [...previous.entries, ...page.entries],
526+ rawConsumed: previous.rawConsumed + page.rawCount,
527+ loadingMore: false,
528+ // Exhaustion keys on the RAW page size: the connectable-kind filter
529+ // shrinks pages, and a failed fetch is not the end of the catalog —
530+ // the sentinel simply tries again next time it scrolls into view.
531+ exhausted: Exit.isSuccess(exit) && page.rawCount < limit,
532+ };
533+ });
534+ });
535+ }, [hasMore, requestKey, query, kind, limit, extras?.rawConsumed]);
536+
537+ const entries = useMemo(() => {
538+ if (!extras || extras.entries.length === 0) return state.entries;
539+ // Ranking can shift between page fetches (the live index moves); a row
540+ // that slid across a page boundary must not render twice.
541+ const seen = new Set(state.entries.map((entry) => `${entry.domain}|${entry.name ?? ""}`));
542+ return [
543+ ...state.entries,
544+ ...extras.entries.filter((entry) => {
545+ const key = `${entry.domain}|${entry.name ?? ""}`;
546+ if (seen.has(key)) return false;
547+ seen.add(key);
548+ return true;
549+ }),
550+ ];
551+ }, [state.entries, extras]);
552+
553+ return {
554+ entries,
555+ loading: state.loading,
556+ failed: state.failed,
557+ /** The rendered entries belong to a PREVIOUS query (held over while the
558+ * current one loads, or while the input is one character). Callers
559+ * decide the treatment; unmarked stale results read as wrong results. */
560+ stale: state.forKey !== requestKey,
561+ hasMore: hasMore && state.forKey === requestKey,
562+ loadingMore: extras?.loadingMore ?? false,
563+ loadMore,
564+ };
565+}
566+
567+// ---------------------------------------------------------------------------
568+// Credential guidance
569+//
570+// The per-domain surface document records, for each way of authenticating,
571+// what the credential is called at the provider, the page that mints it, and
572+// the provider's own setup steps. The console fetched this document for its
573+// connect URL and discarded the rest — which is why the add-connection modal
574+// could not answer "what kind of key do I need?" or "where do I get one?"
575+// while asking for exactly that.
576+// ---------------------------------------------------------------------------
577+
578+export interface CredentialGuidance {
579+ readonly id: string;
580+ readonly type: string;
581+ /** What the provider calls this credential ("Personal API key"). */
582+ readonly label: string;
583+ /** The page that mints it. */
584+ readonly generateUrl?: string;
585+ /** The provider's own instructions, as markdown. */
586+ readonly setup?: string;
587+}
588+
589+const CredentialsDocument = Schema.Struct({
590+ credentials: Schema.optional(
591+ Schema.Record(
592+ Schema.String,
593+ Schema.Struct({
594+ type: Schema.optional(Schema.String),
595+ label: Schema.optional(Schema.String),
596+ generateUrl: Schema.optional(Schema.String),
597+ setup: Schema.optional(Schema.String),
598+ }),
599+ ),
600+ ),
601+});
602+const decodeCredentials = Schema.decodeUnknownOption(CredentialsDocument);
603+
604+export const fetchCredentialGuidance = (
605+ domain: string,
606+): Effect.Effect<readonly CredentialGuidance[], CatalogRequestError> => {
607+ const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN);
608+ return Effect.map(fetchCatalogJson(url), (payload) =>
609+ Option.match(decodeCredentials(payload), {
610+ onNone: () => [],
611+ onSome: ({ credentials }) =>
612+ Object.entries(credentials ?? {}).map(([id, value]) => ({
613+ id,
614+ type: value.type ?? "unknown",
615+ label: value.label ?? id,
616+ ...(value.generateUrl ? { generateUrl: value.generateUrl } : {}),
617+ ...(value.setup ? { setup: value.setup } : {}),
618+ })),
619+ }),
620+ );
621+};
622+
623+const guidanceCache = new Map<string, readonly CredentialGuidance[]>();
624+
625+/** Guidance for a domain, or an empty list when the registry has none. Never
626+ * fails loudly: this is help text, and a connection form must work without it. */
627+export function useCredentialGuidance(domain: string | null): readonly CredentialGuidance[] {
628+ const [state, setState] = useState<readonly CredentialGuidance[]>([]);
629+
630+ useEffect(() => {
631+ if (!domain) {
632+ setState([]);
633+ return;
634+ }
635+ const cached = guidanceCache.get(domain);
636+ if (cached) {
637+ setState(cached);
638+ return;
639+ }
640+ let live = true;
641+ void Effect.runPromiseExit(fetchCredentialGuidance(domain)).then((exit) => {
642+ const value = Exit.isSuccess(exit) ? exit.value : [];
643+ guidanceCache.set(domain, value);
644+ if (live) setState(value);
645+ });
646+ return () => {
647+ live = false;
648+ };
649+ }, [domain]);
253650
254651 return state;
255652 }
packages/react/src/multiplayer/shell.tsx+10−10
@@ -1,4 +1,4 @@
1−import { Link, Outlet, useLocation, useParams } from "@tanstack/react-router";
1+import { Link, Outlet, useLocation, useNavigate, useParams } from "@tanstack/react-router";
22 import { useEffect, useRef, useState, type ReactNode } from "react";
33 import { useAtomValue } from "@effect/atom-react";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -23,7 +23,6 @@ import { · 18 unmodified lines
2323 } from "../components/integration-favicon";
2424 import { CommandPalette } from "../components/command-palette";
2525 import { Wordmark } from "../components/wordmark";
26−import { ConnectDialog } from "../pages/integrations";
2726 import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
2827 import { useAuth } from "./auth-context";
2928
@@ -428,7 +427,13 @@ export function Shell(props: ShellProps) { · 398 unmodified lines
428427 const lastPathname = useRef(pathname);
429428 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
430429 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+ };
432437 if (lastPathname.current !== pathname) {
433438 lastPathname.current = pathname;
434439 if (mobileSidebarOpen) setMobileSidebarOpen(false);
@@ -446,17 +451,13 @@ export function Shell(props: ShellProps) { · 11 unmodified lines
446451 return (
447452 <div className="flex h-screen overflow-hidden">
448453 <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
449− <ConnectDialog open={connectIntegrationOpen} onOpenChange={setConnectIntegrationOpen} />
450454 {/* Desktop sidebar */}
451455 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
452456 <SidebarContent
453457 {...props}
454458 pathname={pathname}
455459 onOpenCommands={() => setCommandPaletteOpen(true)}
456− onOpenIntegrationConnect={() => {
457− setConnectIntegrationOpen(true);
458− trackEvent("integration_connect_dialog_opened");
459− }}
460+ onOpenIntegrationConnect={openIntegrationBrowse}
460461 />
461462 </aside>
462463
@@ -502,8 +503,7 @@ export function Shell(props: ShellProps) { · 39 unmodified lines
502503 }}
503504 onOpenIntegrationConnect={() => {
504505 setMobileSidebarOpen(false);
505− setConnectIntegrationOpen(true);
506− trackEvent("integration_connect_dialog_opened");
506+ openIntegrationBrowse();
507507 }}
508508 />
509509 </div>
packages/react/src/pages/integration-add.tsx+10−1
@@ -15,9 +15,14 @@ export function AddIntegrationPage(props: { · 14 unmodified lines
1515 url?: string;
1616 preset?: string;
1717 namespace?: string;
18+ authHeader?: string;
19+ authNote?: string;
20+ authKind?: string;
21+ specOverrides?: string;
1822 }) {
1923 useExecutorDocumentTitle("Add integration");
20− const { pluginKey, url, preset, namespace } = props;
24+ const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } =
25+ props;
2126 const navigate = useNavigate();
2227 const integrationPlugins = useIntegrationPlugins();
2328 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
@@ -57,6 +62,10 @@ export function AddIntegrationPage(props: { · 33 unmodified lines
5762 initialUrl={url}
5863 initialPreset={preset}
5964 initialNamespace={namespace}
65+ initialAuthHeader={authHeader}
66+ initialAuthNote={authNote}
67+ initialAuthKind={authKind}
68+ initialSpecOverrides={specOverrides}
6069 onComplete={(slug?: string) => {
6170 trackEvent("integration_added", {
6271 plugin_key: pluginKey,
packages/react/src/pages/integration-browse.tsx+1041−0
@@ -0,0 +1,1041 @@
1+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2+import { Link, useNavigate } from "@tanstack/react-router";
3+import { useAtomSet, useAtomValue } from "@effect/atom-react";
4+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5+import * as Effect from "effect/Effect";
6+import * as Exit from "effect/Exit";
7+import { PlusIcon, SearchIcon } from "lucide-react";
8+import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared";
9+import {
10+ useIntegrationPlugins,
11+ type IntegrationPlugin,
12+ type IntegrationPreset,
13+ type IntegrationQuickAddInput,
14+ type IntegrationQuickAddResult,
15+} from "@executor-js/sdk/client";
16+
17+import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms";
18+import { slugifyNamespace } from "../plugins/namespace";
19+import { trackEvent } from "../api/analytics";
20+import { Button } from "../components/button";
21+import { Input } from "../components/input";
22+import { PageHeader } from "../components/page";
23+import {
24+ integrationFaviconUrl,
25+ integrationInferredUrl,
26+ integrationPresetIconUrl,
27+} from "../components/integration-favicon";
28+import { Skeleton } from "../components/skeleton";
29+import { useExecutorDocumentTitle } from "../lib/document-title";
30+import {
31+ availableCatalogKinds,
32+ catalogLogoUrl,
33+ filterCatalogEntries,
34+ resolveConnectTarget,
35+ useCatalogBrowse,
36+ type CatalogKind,
37+ type CatalogSearchEntry,
38+ type CatalogSurface,
39+} from "../lib/integrations-sh-catalog";
40+
41+// ---------------------------------------------------------------------------
42+// The full-page integration picker.
43+//
44+// Replaces the connect dialog. Finding something to connect is the first task
45+// of onboarding, so it gets a page and a permanent, focused search field rather
46+// than a 16rem scroll window behind a header button.
47+//
48+// ONE ROW PER ADDABLE THING. A service exposing both an API and an MCP server
49+// yields two rows, because they really are two different integrations with
50+// different tools and different auth — but each row NAMES its surface
51+// ("Stripe API", "Stripe MCP") rather than repeating the bare service name and
52+// leaving a badge to carry the difference. Two rows reading "Stripe" look like
53+// a duplicate; two rows reading "Stripe API" and "Stripe MCP" look like a
54+// choice, which is what they are.
55+//
56+// THE REGISTRY IS THE LIST. Every connectable card is a registry row: the
57+// registry carries the name, domain, description, spec or endpoint, auth
58+// facts, and corrective spec overrides, and the spec itself declares how to
59+// authenticate — a deployment's first-party OAuth clients bind at connect
60+// time by endpoint host, not through the picker. The one exception is
61+// presets WITHOUT a connect URL (local-process servers like Chrome DevTools
62+// over stdio): those cannot be registry rows yet, so they remain cards.
63+//
64+// FACETS ARE SURFACE KIND, NOT CATEGORY. There is no taxonomy to facet on, so a
65+// category rail would have to invent one. Kind is real, filtered server-side.
66+// ---------------------------------------------------------------------------
67+
68+const KIND_TO_PLUGIN_KEY: Record<string, string> = {
69+ openapi: "openapi",
70+ mcp: "mcp",
71+ graphql: "graphql",
72+ googleDiscovery: "google",
73+};
74+
75+const CATALOG_KIND_LABEL: Record<CatalogKind, string> = {
76+ mcp: "MCP",
77+ openapi: "OpenAPI",
78+ graphql: "GraphQL",
79+};
80+
81+/** How a surface is said in a row's NAME. Deliberately not the facet
82+ * vocabulary: a facet filters on the spec format ("OpenAPI"), while a name
83+ * says the thing you would say out loud ("Stripe API"). */
84+const SURFACE_WORD: Record<string, string> = {
85+ mcp: "MCP",
86+ openapi: "API",
87+ google: "API",
88+ graphql: "GraphQL",
89+};
90+
91+/** `Stripe` + API → `Stripe API`, but `GitHub REST` + API stays `GitHub REST`
92+ * and `Emulate MCP` + MCP stays `Emulate MCP`. Appending a word the name
93+ * already carries reads worse than leaving it off. */
94+const withSurface = (name: string, surface: string): string => {
95+ const lower = name.toLowerCase();
96+ const already =
97+ lower.includes(surface.toLowerCase()) ||
98+ (surface === "API" && (lower.includes("api") || lower.includes("rest")));
99+ return already ? name : `${name} ${surface}`;
100+};
101+
102+/** The key on which a preset and a catalog row count as the same product:
103+ * the name with surface words and punctuation stripped, plus the kind.
104+ * "GitHub REST" and "GitHub API" are one product; "Stripe API" and
105+ * "Stripe MCP" are not. */
106+const productKey = (name: string, kind: string): string =>
107+ `${name
108+ .toLowerCase()
109+ .replace(/\b(api|rest|graphql|mcp)\b/g, "")
110+ .replace(/[^a-z0-9]/g, "")}|${kind}`;
111+
112+const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = {
113+ high: 3,
114+ medium: 2,
115+ low: 1,
116+};
117+
118+const bestDetection = (
119+ results: readonly IntegrationDetectionResult[],
120+): IntegrationDetectionResult | undefined =>
121+ [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0];
122+
123+/** The input either names a thing to look for or points at one. Anything with
124+ * a scheme, a slash, or a host-with-TLD is a URL; everything else is a query. */
125+const looksLikeUrl = (raw: string): boolean => {
126+ const value = raw.trim();
127+ if (value.length === 0) return false;
128+ if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(value)) return true;
129+ if (value.includes("/")) return true;
130+ if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(value)) return true;
131+ return false;
132+};
133+
134+/** A readable product name for a bare domain: `gmail.googleapis.com` reads as
135+ * "Gmail", `linear.app` as "Linear". The full domain still renders beside it,
136+ * because two services can share a leading label and the domain is what
137+ * actually disambiguates them. */
138+const domainDisplayName = (domain: string): string => {
139+ const host = domain.replace(/^www\./, "");
140+ const label = host.split(".")[0] ?? host;
141+ return label.charAt(0).toUpperCase() + label.slice(1);
142+};
143+
144+/** The registry truncates descriptions to a fixed width, which lands mid-word
145+ * ("…read, manage, and send m"). Trim back to the last whole word so the cut
146+ * reads as deliberate. */
147+const tidyDescription = (text: string): string => {
148+ const trimmed = text.trim();
149+ if (trimmed.length === 0) return trimmed;
150+ if (/[.!?]$/.test(trimmed)) return trimmed;
151+ const lastSpace = trimmed.lastIndexOf(" ");
152+ return `${(lastSpace > 40 ? trimmed.slice(0, lastSpace) : trimmed).replace(/[,;:]$/, "")}…`;
153+};
154+
155+type PresetEntry = {
156+ readonly preset: IntegrationPreset;
157+ readonly pluginKey: string;
158+ readonly pluginLabel: string;
159+};
160+
161+interface Row {
162+ readonly key: string;
163+ readonly testId: string;
164+ readonly title: string;
165+ /** Surface kind for product matching (productKey); not rendered. */
166+ readonly kindKey: string;
167+ /** The domain, shown beside a prettified title so near-namesakes stay
168+ * distinguishable. Omitted when the title is already the domain. */
169+ readonly domain?: string;
170+ readonly description?: string;
171+ readonly iconUrl?: string;
172+ readonly onSelect: () => void;
173+ readonly added: boolean;
174+ /** Namespace of the installed integration this row matched — the View
175+ * destination. Known for quick-added rows and for rows whose installed
176+ * match came from the catalog list. */
177+ readonly viewSlug?: string;
178+ /** Added DURING this picker session (quick add). Excluded from the
179+ * added-first float: the card the user just clicked must keep its place —
180+ * teleporting it to the top mid-interaction is exactly the jumping this
181+ * page keeps having to unlearn. It floats on the next visit. */
182+ readonly freshlyAdded?: boolean;
183+ readonly busy: boolean;
184+ /** A click-time failure, rendered on this card rather than page-top. */
185+ readonly error?: string;
186+}
187+
188+// ---------------------------------------------------------------------------
189+// Infinite scroll
190+// ---------------------------------------------------------------------------
191+
192+/** Fires `onVisible` whenever the sentinel scrolls near the viewport. The
193+ * rootMargin starts the next page a screen early, so scrolling never visibly
194+ * hits the bottom of a still-growing list. */
195+function LoadMoreSentinel(props: { readonly onVisible: () => void }) {
196+ const ref = useRef<HTMLDivElement | null>(null);
197+ const { onVisible } = props;
198+ useEffect(() => {
199+ const node = ref.current;
200+ if (!node) return;
201+ const observer = new IntersectionObserver(
202+ (observed) => {
203+ if (observed.some((entry) => entry.isIntersecting)) onVisible();
204+ },
205+ { rootMargin: "600px" },
206+ );
207+ observer.observe(node);
208+ return () => observer.disconnect();
209+ }, [onVisible]);
210+ return <div ref={ref} aria-hidden className="h-px" />;
211+}
212+
213+// ---------------------------------------------------------------------------
214+// Row
215+// ---------------------------------------------------------------------------
216+
217+/** Registry logos 404 for plenty of hosts; a broken-image glyph in a list of
218+ * brand marks looks like a bug, so failures fall back to a neutral mark. */
219+function RowIcon(props: { readonly src?: string; readonly alt: string }) {
220+ const [failed, setFailed] = useState(false);
221+ if (!props.src || failed) {
222+ return (
223+ <span
224+ aria-hidden
225+ className="flex size-5 items-center justify-center rounded-sm bg-muted text-[10px] font-medium text-muted-foreground"
226+ >
227+ {props.alt.charAt(0).toUpperCase()}
228+ </span>
229+ );
230+ }
231+ return (
232+ <img
233+ src={props.src}
234+ alt=""
235+ width={20}
236+ height={20}
237+ // NOT lazy. The console scrolls inside a nested container, and Chrome's
238+ // lazy loading never brings these into view there — every icon sits
239+ // pending forever while an eager load of the same URL succeeds instantly.
240+ // A 20px favicon is not worth deferring anyway.
241+ onError={() => setFailed(true)}
242+ className="size-5 object-contain"
243+ />
244+ );
245+}
246+
247+function ResultCard(props: { readonly row: Row }) {
248+ const { row } = props;
249+ return (
250+ <div
251+ data-testid={row.testId}
252+ className="flex min-h-[8.5rem] flex-col gap-2.5 rounded-lg border border-border/60 p-4 transition-colors hover:border-border hover:bg-accent/20"
253+ >
254+ <div className="flex items-start justify-between gap-2">
255+ <span className="flex size-8 shrink-0 items-center justify-center">
256+ <RowIcon {...(row.iconUrl ? { src: row.iconUrl } : {})} alt={row.title} />
257+ </span>
258+ {row.added ? (
259+ // The add happened HERE — the card is the receipt. View jumps to
260+ // the integration's hub, where authenticating happens.
261+ row.viewSlug ? (
262+ <Button asChild variant="outline" size="sm" aria-label={`View ${row.title}`}>
263+ <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: row.viewSlug }}>
264+ View
265+ </Link>
266+ </Button>
267+ ) : (
268+ <span className="px-2 py-1.5 text-xs font-medium text-muted-foreground">Added</span>
269+ )
270+ ) : row.busy ? (
271+ <span className="px-2 py-1.5 text-xs text-muted-foreground">Adding…</span>
272+ ) : (
273+ <Button
274+ type="button"
275+ variant="outline"
276+ size="sm"
277+ onClick={row.onSelect}
278+ // Every card's button reads "Add", so the visible label alone is
279+ // useless to a screen reader; the accessible name carries the card.
280+ aria-label={`Add ${row.title}`}
281+ >
282+ Add
283+ </Button>
284+ )}
285+ </div>
286+ <div className="min-w-0">
287+ <p className="truncate text-sm font-medium text-foreground">{row.title}</p>
288+ {row.domain ? (
289+ <p className="truncate font-mono text-[11px] text-muted-foreground/70">{row.domain}</p>
290+ ) : null}
291+ </div>
292+ {row.description ? (
293+ <p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">
294+ {row.description}
295+ </p>
296+ ) : null}
297+ {row.error ? (
298+ <p role="alert" className="text-xs text-destructive">
299+ {row.error}
300+ </p>
301+ ) : null}
302+ </div>
303+ );
304+}
305+
306+function CardSkeleton(props: { readonly index: number }) {
307+ const { index } = props;
308+ return (
309+ <div className="flex flex-col gap-2.5 rounded-lg border border-border/60 p-4">
310+ <div className="flex items-start justify-between gap-2">
311+ <Skeleton className="size-8 rounded-md" />
312+ <Skeleton className="h-8 w-16 rounded-md" />
313+ </div>
314+ <div className="space-y-1.5">
315+ <Skeleton className="h-3.5" style={{ width: `${40 + ((index * 11) % 30)}%` }} />
316+ <Skeleton className="h-3" style={{ width: `${55 + ((index * 13) % 30)}%` }} />
317+ </div>
318+ <Skeleton className="h-3" style={{ width: `${70 + ((index * 7) % 25)}%` }} />
319+ </div>
320+ );
321+}
322+
323+// ---------------------------------------------------------------------------
324+// Quick add
325+// ---------------------------------------------------------------------------
326+
327+type QuickAddFn = (input: IntegrationQuickAddInput) => Promise<IntegrationQuickAddResult>;
328+
329+/** Collects each plugin's bound quick-add callback into the page's map.
330+ * A component per plugin, not a hook over a list: `useQuickAdd` is a hook,
331+ * and mounting one bridge per plugin is how a fixed set of per-plugin hooks
332+ * stays rules-of-hooks clean. */
333+function QuickAddBridge(props: {
334+ readonly pluginKey: string;
335+ /** The plugin's hook, called UNCONDITIONALLY — the host renders a bridge
336+ * only for plugins that have one, so this component's hook sequence never
337+ * depends on a plugin object's shape. */
338+ readonly useQuickAdd: () => QuickAddFn;
339+ readonly register: (key: string, fn: QuickAddFn | null) => void;
340+}) {
341+ const { pluginKey, useQuickAdd, register } = props;
342+ const fn = useQuickAdd();
343+ useEffect(() => {
344+ register(pluginKey, fn);
345+ return () => register(pluginKey, null);
346+ }, [pluginKey, fn, register]);
347+ return null;
348+}
349+
350+/** One bridge per plugin KEY: a duplicate key would share the React key and
351+ * the callback slot, letting either copy's cleanup delete the other. */
352+function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) {
353+ const seen = new Set<string>();
354+ return plugins.filter((plugin) => {
355+ if (!plugin.useQuickAdd || seen.has(plugin.key)) return false;
356+ seen.add(plugin.key);
357+ return true;
358+ });
359+}
360+
361+// ---------------------------------------------------------------------------
362+// Page
363+// ---------------------------------------------------------------------------
364+
365+export function IntegrationBrowsePage() {
366+ useExecutorDocumentTitle("Add an integration");
367+ const navigate = useNavigate();
368+ const integrationPlugins = useIntegrationPlugins();
369+ const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });
370+ const installed = useAtomValue(integrationsOptimisticAtom);
371+
372+ const [query, setQuery] = useState("");
373+ const [detecting, setDetecting] = useState(false);
374+ const [error, setError] = useState<string | null>(null);
375+ // Resolve-on-click failures belong on the card that was clicked — a page-top
376+ // alert for a card three screens down reads as a silent failure.
377+ const [rowError, setRowError] = useState<{
378+ readonly key: string;
379+ readonly message: string;
380+ } | null>(null);
381+ const [resolvingDomain, setResolvingDomain] = useState<string | null>(null);
382+ // One-click adds run per-card and CONCURRENTLY — adding Linear must not
383+ // lock the Notion card. Keyed by row error-key (`domain|kind`).
384+ const [quickAddingKeys, setQuickAddingKeys] = useState<ReadonlySet<string>>(new Set());
385+ // Slugs of integrations added from this page, keyed the same way, so the
386+ // card can flip to View without waiting for the catalog refetch.
387+ const [quickAddedSlugs, setQuickAddedSlugs] = useState<ReadonlyMap<string, string>>(new Map());
388+ const quickAdders = useRef(new Map<string, QuickAddFn>());
389+ const registerQuickAdd = useCallback((key: string, fn: QuickAddFn | null) => {
390+ if (fn) quickAdders.current.set(key, fn);
391+ else quickAdders.current.delete(key);
392+ }, []);
393+
394+ const isUrl = looksLikeUrl(query);
395+ // A URL is a destination, not a filter — but half-typed URLs pass through
396+ // here on every keystroke ("stripe.co" parses as one), and swapping the
397+ // grid back to the browse head mid-typing made the whole page jump. Freeze
398+ // the list on the last real query instead; the detect hint is what changes.
399+ const lastTextQuery = useRef("");
400+ if (!isUrl) lastTextQuery.current = query;
401+ const listQuery = isUrl ? lastTextQuery.current : query;
402+ const text = listQuery.trim().toLowerCase();
403+
404+ const availableKinds = useMemo(
405+ () => availableCatalogKinds(integrationPlugins),
406+ [integrationPlugins],
407+ );
408+ // Nothing to exclude now that the registry is the only source.
409+ const excludeDomains = useMemo(() => new Set<string>(), []);
410+
411+ const catalog = useCatalogBrowse({ query: listQuery });
412+ const catalogEntries = useMemo(() => {
413+ const usable = filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds });
414+ if (!catalog.stale) return usable;
415+ // Held-over results from the previous query: keep only what still reads
416+ // as an answer to the live text. Every whitespace token must appear in
417+ // the entry's name or domain — refining "google"→"google cal" keeps
418+ // cloud.google.com rows (the match lives in the domain), while a new
419+ // word drops the old rows instead of parading them under the wrong
420+ // query, which is how a calendar search showed Gmail. An empty text
421+ // (clearing back to browse) holds nothing.
422+ const tokens = listQuery.trim().toLowerCase().split(/\s+/).filter(Boolean);
423+ if (tokens.length === 0) return [];
424+ return usable.filter((entry) => {
425+ const haystack =
426+ `${entry.name ?? domainDisplayName(entry.domain)} ${entry.domain}`.toLowerCase();
427+ return tokens.every((token) => haystack.includes(token));
428+ });
429+ }, [catalog.entries, catalog.stale, listQuery, excludeDomains, availableKinds]);
430+
431+ /** What is already connected, as `<slug>:<kind>`.
432+ *
433+ * The slug is the closest thing to an identity available today — it is the
434+ * namespace the add flow seeds from a registry surface's `slug` or a
435+ * preset's name. Kind is carried alongside it because slugs are NOT unique
436+ * per surface: the OpenAPI and MCP presets for Stripe, Neon, Sentry and
437+ * Axiom share a name and an id, so both seed the same namespace. Without the
438+ * kind, adding one marks the other added — the same false positive the
439+ * earlier domain match produced.
440+ *
441+ * This still under-reports: a connection the user renamed on the way in no
442+ * longer matches, and reads as not-added. That is deliberate — offering to
443+ * add something twice is recoverable, claiming something is connected when
444+ * it is not is not. The real fix is recording at add time which row an
445+ * integration came from; nothing here can infer it after the fact. */
446+ const installedKeys = useMemo(() => {
447+ const rows: readonly Integration[] = AsyncResult.isSuccess(installed) ? installed.value : [];
448+ return new Set(
449+ rows.map((row) => `${String(row.slug)}:${KIND_TO_PLUGIN_KEY[row.kind] ?? row.kind}`),
450+ );
451+ }, [installed]);
452+
453+ const isAdded = useCallback(
454+ (kind: string, ...candidates: readonly (string | undefined)[]): boolean =>
455+ candidates.some((candidate) => {
456+ if (!candidate) return false;
457+ return (
458+ installedKeys.has(`${candidate}:${kind}`) ||
459+ installedKeys.has(`${slugifyNamespace(candidate)}:${kind}`)
460+ );
461+ }),
462+ [installedKeys],
463+ );
464+
465+ /** The installed integration's own namespace for an added row — the View
466+ * button's destination. Same matching rules as `isAdded`. */
467+ const installedSlugFor = useCallback(
468+ (kind: string, ...candidates: readonly (string | undefined)[]): string | undefined => {
469+ for (const candidate of candidates) {
470+ if (!candidate) continue;
471+ if (installedKeys.has(`${candidate}:${kind}`)) return candidate;
472+ const slugified = slugifyNamespace(candidate);
473+ if (installedKeys.has(`${slugified}:${kind}`)) return slugified;
474+ }
475+ return undefined;
476+ },
477+ [installedKeys],
478+ );
479+
480+ const allPresets = useMemo(() => {
481+ const entries: PresetEntry[] = [];
482+ for (const plugin of integrationPlugins) {
483+ for (const preset of plugin.presets ?? []) {
484+ entries.push({ preset, pluginKey: plugin.key, pluginLabel: plugin.label });
485+ }
486+ }
487+ return entries;
488+ }, [integrationPlugins]);
489+
490+ const handleDetect = useCallback(async () => {
491+ const trimmed = query.trim();
492+ if (trimmed.length === 0) return;
493+ setDetecting(true);
494+ setError(null);
495+ // Detection is read-only — it inspects a URL and returns candidates without
496+ // mutating the catalog, so it invalidates nothing.
497+ const exit = await doDetect({ payload: { url: trimmed }, reactivityKeys: [] });
498+ if (Exit.isFailure(exit)) {
499+ trackEvent("integration_detect_submitted", { success: false });
500+ setError("Couldn't reach that URL. Check it, or start from scratch below.");
501+ setDetecting(false);
502+ return;
503+ }
504+ const detected = bestDetection(exit.value);
505+ if (!detected) {
506+ trackEvent("integration_detect_submitted", { success: false });
507+ setError("Couldn't tell what that URL exposes. Start from scratch below.");
508+ setDetecting(false);
509+ return;
510+ }
511+ trackEvent("integration_detect_submitted", {
512+ success: true,
513+ detected_kind: detected.kind,
514+ confidence: detected.confidence,
515+ });
516+ const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind] ?? detected.kind;
517+ if (!integrationPlugins.some((plugin) => plugin.key === pluginKey)) {
518+ setError(`That looks like a ${detected.kind} integration, which this server can't add.`);
519+ setDetecting(false);
520+ return;
521+ }
522+ trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" });
523+ void navigate({
524+ to: "/{-$orgSlug}/integrations/add/$pluginKey",
525+ params: { pluginKey },
526+ search: { url: trimmed, namespace: detected.slug },
527+ });
528+ }, [query, doDetect, navigate, integrationPlugins]);
529+
530+ const goToAdd = useCallback(
531+ (input: {
532+ readonly kind: string;
533+ readonly url: string;
534+ readonly slug?: string;
535+ readonly domain: string;
536+ readonly auth?: CatalogSurface["auth"];
537+ readonly specOverrides?: CatalogSurface["specOverrides"];
538+ }) => {
539+ trackEvent("integration_add_started", {
540+ plugin_key: input.kind,
541+ via: "catalog",
542+ catalog_domain: input.domain,
543+ });
544+ void navigate({
545+ to: "/{-$orgSlug}/integrations/add/$pluginKey",
546+ params: { pluginKey: input.kind },
547+ search: {
548+ url: input.url,
549+ ...(input.slug ? { namespace: input.slug } : {}),
550+ ...(input.auth?.header ? { authHeader: input.auth.header } : {}),
551+ ...(input.auth?.note ? { authNote: input.auth.note } : {}),
552+ ...(input.auth?.kind ? { authKind: input.auth.kind } : {}),
553+ ...(input.specOverrides ? { specOverrides: JSON.stringify(input.specOverrides) } : {}),
554+ },
555+ });
556+ },
557+ [navigate],
558+ );
559+
560+ /** One-click add, in place. The registry knew the URL and the auth
561+ * indicators, so there is nothing to configure — register directly and
562+ * flip the card to View, leaving the user on this page to keep adding.
563+ * Returns false when it could not (no quick-add for the kind, probe or
564+ * registration failed), and the caller falls back to the configuration
565+ * screen, which renders the same failure with full context. */
566+ const tryQuickAdd = useCallback(
567+ async (input: {
568+ readonly kind: CatalogKind;
569+ readonly url: string;
570+ readonly title: string;
571+ readonly domain: string;
572+ /** The row's identity key — carries the PRODUCT, not just domain|kind:
573+ * one domain can offer two products of the same kind (the Google
574+ * Photos Library and Picker), and a domain-level key marked both cards
575+ * added when either one was. */
576+ readonly rowKey: string;
577+ readonly slug?: string;
578+ readonly auth?: CatalogSurface["auth"];
579+ readonly specOverrides?: CatalogSurface["specOverrides"];
580+ }): Promise<boolean> => {
581+ const fn = quickAdders.current.get(KIND_TO_PLUGIN_KEY[input.kind] ?? input.kind);
582+ if (!fn) return false;
583+ const rowKey = input.rowKey;
584+ setQuickAddingKeys((previous) => new Set(previous).add(rowKey));
585+ trackEvent("integration_add_started", {
586+ plugin_key: input.kind,
587+ via: "catalog",
588+ catalog_domain: input.domain,
589+ });
590+ const result = await fn({
591+ url: input.url,
592+ name: input.title,
593+ domain: input.domain,
594+ ...(input.slug ? { slug: input.slug } : {}),
595+ ...(input.auth?.header ? { authHeader: input.auth.header } : {}),
596+ ...(input.auth?.kind ? { authKind: input.auth.kind } : {}),
597+ ...(input.specOverrides ? { specOverrides: input.specOverrides } : {}),
598+ });
599+ setQuickAddingKeys((previous) => {
600+ const next = new Set(previous);
601+ next.delete(rowKey);
602+ return next;
603+ });
604+ if (!result.ok) return false;
605+ setQuickAddedSlugs((previous) => new Map(previous).set(rowKey, result.slug));
606+ return true;
607+ },
608+ [],
609+ );
610+
611+ const pickCatalogEntry = useCallback(
612+ async (
613+ entry: CatalogSearchEntry,
614+ kind: CatalogKind,
615+ title: string,
616+ rowKey: string,
617+ // The EXACT surface this card was built from. Never re-derived from
618+ // `kind`: one domain can carry two same-kind products (Google Photos
619+ // Library and Picker), and a kind lookup silently merged the clicked
620+ // card's URL with the FIRST surface's slug, auth, and overrides.
621+ surface?: CatalogSurface,
622+ ) => {
623+ const knownUrl = surface?.url;
624+ if (knownUrl) {
625+ const added = await tryQuickAdd({
626+ kind,
627+ url: knownUrl,
628+ title,
629+ domain: entry.domain,
630+ rowKey,
631+ ...(surface ? { slug: surface.slug } : {}),
632+ ...(surface?.auth ? { auth: surface.auth } : {}),
633+ ...(surface?.specOverrides ? { specOverrides: surface.specOverrides } : {}),
634+ });
635+ if (added) return;
636+ goToAdd({
637+ kind,
638+ url: knownUrl,
639+ domain: entry.domain,
640+ ...(surface ? { slug: surface.slug } : {}),
641+ ...(surface?.auth ? { auth: surface.auth } : {}),
642+ ...(surface?.specOverrides ? { specOverrides: surface.specOverrides } : {}),
643+ });
644+ return;
645+ }
646+ if (resolvingDomain !== null) return;
647+ setResolvingDomain(entry.domain);
648+ setRowError(null);
649+ // The row's product name disambiguates same-kind surfaces in the doc
650+ // (the registry row itself was kinds-only, so the name is all we have).
651+ const exit = await Effect.runPromiseExit(
652+ resolveConnectTarget(entry.domain, [kind], entry.name ?? undefined),
653+ );
654+ setResolvingDomain(null);
655+ if (Exit.isFailure(exit) || !exit.value) {
656+ setRowError({
657+ key: rowKey,
658+ message: "Couldn't load connect details. Paste its URL above instead.",
659+ });
660+ return;
661+ }
662+ const target = exit.value;
663+ const added = await tryQuickAdd({
664+ kind: target.kind,
665+ url: target.url,
666+ title,
667+ domain: entry.domain,
668+ rowKey,
669+ ...(target.slug ? { slug: target.slug } : {}),
670+ });
671+ if (added) return;
672+ goToAdd({
673+ kind: target.kind,
674+ url: target.url,
675+ domain: entry.domain,
676+ ...(target.slug ? { slug: target.slug } : {}),
677+ });
678+ },
679+ [goToAdd, resolvingDomain, tryQuickAdd],
680+ );
681+
682+ const pickPreset = useCallback(
683+ (entry: PresetEntry) => {
684+ trackEvent("integration_add_started", {
685+ plugin_key: entry.pluginKey,
686+ via: "preset",
687+ preset_id: entry.preset.id,
688+ });
689+ void navigate({
690+ to: "/{-$orgSlug}/integrations/add/$pluginKey",
691+ params: { pluginKey: entry.pluginKey },
692+ search: { preset: entry.preset.id },
693+ });
694+ },
695+ [navigate],
696+ );
697+
698+ // --- Preset rows ---------------------------------------------------------
699+ //
700+ // Only presets WITHOUT a connect URL remain cards: a preset with a URL is a
701+ // registry row's job now, while a local-process server (Chrome DevTools over
702+ // stdio) has no registry representation yet.
703+ const presetRows = useMemo<readonly Row[]>(() => {
704+ const rows: Row[] = [];
705+ for (const entry of allPresets) {
706+ // A URL preset the registry lists is the registry row's job. A custom
707+ // deployment preset has no registry row — hiding it would make a
708+ // private API undiscoverable, so it keeps its card.
709+ if (
710+ (entry.preset.url !== undefined || entry.preset.endpoint !== undefined) &&
711+ entry.preset.registryListed === true
712+ )
713+ continue;
714+ if (text.length > 0) {
715+ const corpus =
716+ `${entry.preset.name} ${entry.preset.summary ?? ""} ${entry.preset.family ?? ""} ${entry.pluginLabel}`.toLowerCase();
717+ if (!corpus.includes(text)) continue;
718+ }
719+ const surface = SURFACE_WORD[entry.pluginKey] ?? entry.pluginLabel;
720+ const title = withSurface(entry.preset.name, surface);
721+ const kindKey = entry.pluginKey === "google" ? "openapi" : entry.pluginKey;
722+ rows.push({
723+ key: `preset-${entry.pluginKey}-${entry.preset.id}`,
724+ testId: `preset-${entry.pluginKey}-${entry.preset.id}`,
725+ title,
726+ kindKey,
727+ ...(entry.preset.summary ? { description: entry.preset.summary } : {}),
728+ ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}),
729+ onSelect: () => pickPreset(entry),
730+ // The rendered title is also what the add flow derives the namespace
731+ // from, so the card recognises an add made through it even when the
732+ // preset declares no defaultSlug and its bare name would miss.
733+ added: isAdded(entry.pluginKey, entry.preset.defaultSlug, entry.preset.name, title),
734+ busy: false,
735+ });
736+ }
737+ return rows;
738+ }, [allPresets, text, pickPreset, isAdded]);
739+
740+ // --- Catalog rows: one per (service, surface) -----------------------------
741+ const catalogRows = useMemo<readonly Row[]>(() => {
742+ let rows = catalogEntries.flatMap((entry): readonly Row[] => {
743+ const pretty = entry.name ?? domainDisplayName(entry.domain);
744+ const description = entry.description ? tidyDescription(entry.description) : undefined;
745+ // Prefer the registry's own per-surface records, and within them only
746+ // the surfaces that carry a connect target: the registry can know a
747+ // surface EXISTS without knowing where it lives (conjur.org's OpenAPI
748+ // has no recorded spec URL), and a card whose Add can only fail is
749+ // worse than no card. Kinds-only entries (older registries, stubs) keep
750+ // the resolve-on-click fallback.
751+ const surfaces: readonly (CatalogSurface | { readonly kind: CatalogKind })[] =
752+ entry.surfaces && entry.surfaces.length > 0
753+ ? entry.surfaces.filter((surface) => surface.url)
754+ : entry.kinds.map((kind) => ({ kind }));
755+ return surfaces.map((surface): Row => {
756+ const known = "slug" in surface ? surface : null;
757+ const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind];
758+ const title = withSurface(pretty, word);
759+ // Product identity, not just domain|kind: two same-kind products can
760+ // share a domain (Google Photos Library and Picker).
761+ const rowKey = `${entry.domain}|${surface.kind}|${known?.slug ?? pretty}`;
762+ const quickAddedSlug = quickAddedSlugs.get(rowKey);
763+ // Same candidate set as `added`: namespaces come from the registry
764+ // slug (quick add), the domain, or the display name (classic add
765+ // pages), depending on how the integration got here.
766+ const viewSlug =
767+ quickAddedSlug ?? installedSlugFor(surface.kind, known?.slug, entry.domain, title);
768+ // Slug in the key: one domain can carry many product surfaces of the
769+ // same kind (Microsoft Graph's workloads all live on one domain).
770+ return {
771+ key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`,
772+ testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`,
773+ title,
774+ kindKey: surface.kind,
775+ domain: entry.domain,
776+ ...(description ? { description } : {}),
777+ iconUrl:
778+ (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10),
779+ onSelect: () =>
780+ void pickCatalogEntry(entry, surface.kind, title, rowKey, known ?? undefined),
781+ added:
782+ quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug, entry.domain, title),
783+ ...(quickAddedSlug !== undefined ? { freshlyAdded: true } : {}),
784+ ...(viewSlug ? { viewSlug } : {}),
785+ busy: resolvingDomain === entry.domain || quickAddingKeys.has(rowKey),
786+ ...(rowError?.key === rowKey ? { error: rowError.message } : {}),
787+ };
788+ });
789+ });
790+ // A registry row for something a preset already offers is a worse copy of
791+ // it: same service, no auth template, no health check. Matched on
792+ // productKey — normalized name plus kind — because neither titles nor
793+ // domains line up reliably: the Gmail preset's icon is Google's (so a
794+ // domain comparison let a second "Gmail API" through), and the GitHub
795+ // preset says "GitHub REST" where the registry row says "GitHub API".
796+ const presetKeys = new Set(presetRows.map((row) => productKey(row.title, row.kindKey)));
797+ rows = rows.filter((row) => !presetKeys.has(productKey(row.title, row.kindKey)));
798+ if (text.length === 0) return rows;
799+ // A name match beats a mention in the blurb: searching "gmail" should not
800+ // rank a CRM that merely describes itself as living inside Gmail above
801+ // Gmail. Stable within each group, so the registry's own order survives.
802+ const isNamed = (row: Row) => `${row.title} ${row.domain ?? ""}`.toLowerCase().includes(text);
803+ return [...rows.filter(isNamed), ...rows.filter((row) => !isNamed(row))];
804+ }, [
805+ catalogEntries,
806+ isAdded,
807+ installedSlugFor,
808+ quickAddedSlugs,
809+ quickAddingKeys,
810+ resolvingDomain,
811+ pickCatalogEntry,
812+ text,
813+ presetRows,
814+ rowError,
815+ ]);
816+
817+ // Local-process cards hold no rank of their own, so each slots in where its
818+ // title falls alphabetically among the surrounding rows — pinning them to
819+ // the top of a popularity-ranked list gave Chrome DevTools pride of place
820+ // over everything. They also WAIT for the catalog: painting a lone Chrome
821+ // DevTools card while the registry loads reads as a one-item catalog, then
822+ // reflows. When the registry is unreachable they render anyway, so the page
823+ // degrades to the cards that still work.
824+ const results = useMemo(() => {
825+ if (catalog.loading && catalogRows.length === 0 && !catalog.failed) return catalogRows;
826+ const merged = [...catalogRows];
827+ for (const preset of presetRows) {
828+ const at = merged.findIndex(
829+ (row) => row.title.localeCompare(preset.title, undefined, { sensitivity: "base" }) > 0,
830+ );
831+ merged.splice(at === -1 ? merged.length : at, 0, preset);
832+ }
833+ // What you already have leads the list — those cards are the ones with a
834+ // state worth seeing (View). Stable within each half, and fresh
835+ // quick-adds hold their place until the next visit.
836+ const floats = (row: Row) => row.added && row.freshlyAdded !== true;
837+ // Installed integrations whose registry rows are NOT in the loaded page
838+ // still belong at the front — "added first" is about the user's catalog,
839+ // not about which sixty rows the registry ranked highest. Synthesized
840+ // from the integration record itself; a loaded row that matched an
841+ // installed integration claims its slug, so nothing appears twice.
842+ const claimed = new Set(merged.flatMap((row) => (row.viewSlug ? [row.viewSlug] : [])));
843+ const installedList: readonly Integration[] = AsyncResult.isSuccess(installed)
844+ ? installed.value
845+ : [];
846+ const installedRows: readonly Row[] = installedList
847+ .filter(
848+ (row) =>
849+ row.canRemove &&
850+ (row.kind === "mcp" || row.kind === "openapi" || row.kind === "graphql") &&
851+ !claimed.has(String(row.slug)) &&
852+ (text.length === 0 ||
853+ row.name.toLowerCase().includes(text) ||
854+ String(row.slug).includes(text)),
855+ )
856+ .map((row): Row => {
857+ // The SAME icon cascade the sidebar runs — preset icon by exact
858+ // identity, else the logo proxy from the integration's own URL. Two
859+ // resolvers for one integration is how the sidebar and the picker
860+ // ended up disagreeing about what DeepWiki looks like.
861+ const iconUrl =
862+ integrationPresetIconUrl(
863+ {
864+ id: String(row.slug),
865+ kind: row.kind,
866+ name: row.name,
867+ ...(row.displayUrl ? { url: row.displayUrl } : {}),
868+ },
869+ integrationPlugins,
870+ ) ??
871+ integrationFaviconUrl(
872+ row.displayUrl ??
873+ integrationInferredUrl({ id: String(row.slug), name: row.name }) ??
874+ undefined,
875+ 10,
876+ );
877+ return {
878+ key: `installed-${String(row.slug)}`,
879+ testId: `installed-${String(row.slug)}`,
880+ title: row.name,
881+ kindKey: row.kind,
882+ ...(row.description && row.description !== row.name
883+ ? { description: tidyDescription(row.description) }
884+ : {}),
885+ ...(iconUrl ? { iconUrl } : {}),
886+ onSelect: () => {},
887+ added: true,
888+ viewSlug: String(row.slug),
889+ busy: false,
890+ };
891+ });
892+ return [...installedRows, ...merged.filter(floats), ...merged.filter((row) => !floats(row))];
893+ }, [
894+ presetRows,
895+ catalogRows,
896+ catalog.loading,
897+ catalog.failed,
898+ installed,
899+ text,
900+ integrationPlugins,
901+ ]);
902+
903+ // Presets are local, so a list that already has them is not empty — show
904+ // skeletons only when there is genuinely nothing on screen yet.
905+ const loading = catalog.loading && results.length === 0;
906+
907+ return (
908+ // PageContainer scrolls the whole page; this page pins the header and
909+ // search, and ONLY the results grid scrolls — an endless list makes the
910+ // page scrollbar meaningless and drags the search box off screen.
911+ <div className="flex min-h-0 flex-1 flex-col" data-slot="page-container">
912+ {quickAddCapablePlugins(integrationPlugins).map((plugin) => (
913+ <QuickAddBridge
914+ key={plugin.key}
915+ pluginKey={plugin.key}
916+ useQuickAdd={plugin.useQuickAdd!}
917+ register={registerQuickAdd}
918+ />
919+ ))}
920+ <div className="mx-auto w-full max-w-4xl shrink-0 px-6 pt-10 lg:px-8 lg:pt-14">
921+ <PageHeader
922+ title="Add an integration"
923+ description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint."
924+ />
925+
926+ <div className="mb-4 flex gap-2">
927+ <div className="relative min-w-0 flex-1">
928+ <SearchIcon
929+ className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
930+ aria-hidden
931+ />
932+ <Input
933+ type="text"
934+ value={query}
935+ onChange={(event) => {
936+ setQuery((event.target as HTMLInputElement).value);
937+ setError(null);
938+ }}
939+ onKeyDown={(event) => {
940+ if (event.key === "Enter" && isUrl) void handleDetect();
941+ }}
942+ placeholder="Search integrations, or paste a URL…"
943+ aria-label="Search integrations, or paste a URL"
944+ disabled={detecting}
945+ // oxlint-disable-next-line jsx_a11y/no-autofocus -- deliberate: searching is the page's only purpose, and it is reached by an explicit "Add integration" action
946+ autoFocus
947+ className="h-11 pl-9 text-sm"
948+ />
949+ </div>
950+ {isUrl ? (
951+ <Button
952+ className="h-11 shrink-0"
953+ onClick={() => void handleDetect()}
954+ disabled={detecting || query.trim().length === 0}
955+ loading={detecting}
956+ >
957+ Add this URL
958+ </Button>
959+ ) : null}
960+ </div>
961+
962+ {/* Above the results, not below: an endless list has no reachable
963+ bottom, and this is the escape hatch for exactly the person the
964+ list is failing. One quiet line; the label carries the action, so
965+ the chips stay bare format names. */}
966+ <div className="mb-8 flex flex-wrap items-center gap-x-3 gap-y-2">
967+ <span className="shrink-0 text-xs text-muted-foreground">Start from scratch:</span>
968+ <div className="flex shrink-0 items-center gap-1.5">
969+ {integrationPlugins.map(
970+ (plugin: IntegrationPlugin): ReactNode => (
971+ // oxlint-disable-next-line react/forbid-elements -- a chip, not a Button variant
972+ <button
973+ key={plugin.key}
974+ type="button"
975+ aria-label={`New ${plugin.label} integration from scratch`}
976+ onClick={() => {
977+ trackEvent("integration_add_started", {
978+ plugin_key: plugin.key,
979+ via: "manual",
980+ });
981+ void navigate({
982+ to: "/{-$orgSlug}/integrations/add/$pluginKey",
983+ params: { pluginKey: plugin.key },
984+ });
985+ }}
986+ className="inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
987+ >
988+ <PlusIcon className="size-3" aria-hidden />
989+ {plugin.label}
990+ </button>
991+ ),
992+ )}
993+ </div>
994+ <span className="ml-auto shrink-0 text-xs text-muted-foreground">
995+ Can&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 @@
1−import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react";
2−import { Link, useNavigate } from "@tanstack/react-router";
3−import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
1+import { Suspense, useMemo, type ReactNode } from "react";
2+import { Link } from "@tanstack/react-router";
3+import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5−import * as Effect from "effect/Effect";
6−import * as Exit from "effect/Exit";
75 import { PlusIcon } from "lucide-react";
8−import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared";
9−import {
10− useIntegrationPlugins,
11− type IntegrationPlugin,
12− type IntegrationPreset,
13−} from "@executor-js/sdk/client";
14−import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms";
6+import type { Integration } from "@executor-js/sdk/shared";
7+import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/client";
8+import { integrationsOptimisticAtom } from "../api/atoms";
159 import { trackEvent } from "../api/analytics";
1610 import { McpInstallCard } from "../components/mcp-install-card";
1711 import { Button } from "../components/button";
1812 import { PageContainer, PageHeader } from "../components/page";
19−import { Badge } from "../components/badge";
20−import { Input } from "../components/input";
21−import {
22− Dialog,
23− DialogContent,
24− DialogDescription,
25− DialogHeader,
26− DialogTitle,
27−} from "../components/dialog";
2813 import {
2914 CardStack,
3015 CardStackContent,
@@ -32,7 +17,6 @@ import { · 1 unmodified line
3217 CardStackEntryActions,
3318 CardStackEntryContent,
3419 CardStackEntryDescription,
35− CardStackEntryMedia,
3620 CardStackEntryTitle,
3721 CardStackHeader,
3822 } from "../components/card-stack";
@@ -41,18 +25,7 @@ import { · 2 unmodified lines
4125 integrationInferredUrl,
4226 integrationPresetIconUrl,
4327 } from "../components/integration-favicon";
44−import { PresetIcon } from "../components/preset-icon";
4528 import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping";
46−import {
47− availableCatalogKinds,
48− catalogLogoUrl,
49− filterCatalogEntries,
50− presetDomains,
51− resolveConnectTarget,
52− useCatalogSearch,
53− type CatalogKind,
54− type CatalogSearchEntry,
55−} from "../lib/integrations-sh-catalog";
5629 import { IntegrationHealthSummary } from "../components/integration-health-summary";
5730 import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";
5831 import { Skeleton } from "../components/skeleton";
@@ -67,17 +40,6 @@ const KIND_TO_PLUGIN_KEY: Record<string, string> = { · 8 unmodified lines
6740 googleDiscovery: "google",
6841 };
6942
70−const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = {
71− high: 3,
72− medium: 2,
73− low: 1,
74−};
75−
76−const bestDetection = (
77− results: readonly IntegrationDetectionResult[],
78−): IntegrationDetectionResult | undefined =>
79− [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0];
80−
8143 // ---------------------------------------------------------------------------
8244 // Page
8345 // ---------------------------------------------------------------------------
@@ -86,7 +48,6 @@ export function IntegrationsPage() { · 2 unmodified lines
8648 useExecutorDocumentTitle("Integrations");
8749 const integrations = useAtomValue(integrationsOptimisticAtom);
8850 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
89− const [connectOpen, setConnectOpen] = useState(false);
9051
9152 return (
9253 <PageContainer>
@@ -94,16 +55,14 @@ export function IntegrationsPage() { · 1 unmodified line
9455 title="Integrations"
9556 description="Tool providers available in this workspace."
9657 actions={
97− <Button
98− onClick={() => {
99− setConnectOpen(true);
100− trackEvent("integration_connect_dialog_opened");
101− }}
102− size="sm"
103− className="gap-1.5"
104− >
105− <PlusIcon className="size-4" />
106− Connect
58+ <Button asChild size="sm" className="gap-1.5">
59+ <Link
60+ to="/{-$orgSlug}/integrations/browse"
61+ onClick={() => trackEvent("integration_browse_opened", { via: "header" })}
62+ >
63+ <PlusIcon className="size-4" />
64+ Add integration
65+ </Link>
10766 </Button>
10867 }
10968 />
@@ -124,14 +83,7 @@ export function IntegrationsPage() { · 14 unmodified lines
12483 ),
12584 onSuccess: ({ value }) => {
12685 if (value.length === 0) {
127− return (
128− <EmptyIntegrations
129− onConnect={() => {
130− setConnectOpen(true);
131− trackEvent("integration_connect_dialog_opened");
132− }}
133− />
134− );
86+ return <EmptyIntegrations />;
13587 }
13688
13789 return (
@@ -142,176 +94,15 @@ export function IntegrationsPage() { · 4 unmodified lines
14294 },
14395 })
14496 )}
145−
146− <ConnectDialog open={connectOpen} onOpenChange={setConnectOpen} />
14797 </PageContainer>
14898 );
14999 }
150100
151−// ---------------------------------------------------------------------------
152−// Connect dialog — URL detection + manual plugin chooser + presets
153−// ---------------------------------------------------------------------------
154−
155−// Heuristic: the input either looks like a URL (auto-detect) or a free-text
156−// search query (filter the preset list). Anything with a scheme, slash, or
157−// host-with-TLD is treated as a URL; everything else is search.
158−const looksLikeUrl = (raw: string): boolean => {
159− const v = raw.trim();
160− if (v.length === 0) return false;
161− if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true;
162− if (v.includes("/")) return true;
163− if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true;
164− return false;
165−};
166−
167−export function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) {
168− const integrationPlugins = useIntegrationPlugins();
169− const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });
170− const navigate = useNavigate();
171−
172− const [query, setQuery] = useState("");
173− const [detecting, setDetecting] = useState(false);
174− const [error, setError] = useState<string | null>(null);
175−
176− const isUrl = looksLikeUrl(query);
177− const presetSearch = isUrl ? "" : query;
178−
179− const closeAndReset = useCallback(() => {
180− setQuery("");
181− setError(null);
182− setDetecting(false);
183− props.onOpenChange(false);
184− }, [props]);
185−
186− const handleDetect = useCallback(async () => {
187− const trimmed = query.trim();
188− if (!trimmed) return;
189− setDetecting(true);
190− setError(null);
191− // Detection is read-only — it inspects a URL and returns candidates without
192− // mutating the catalog, so it invalidates nothing.
193− const exit = await doDetect({
194− payload: { url: trimmed },
195− reactivityKeys: [],
196− });
197− if (Exit.isFailure(exit)) {
198− trackEvent("integration_detect_submitted", { success: false });
199− setError("Detection failed. Try adding an integration manually.");
200− setDetecting(false);
201− return;
202− }
203− const results = exit.value;
204− if (results.length === 0) {
205− trackEvent("integration_detect_submitted", { success: false });
206− setError("Could not detect an integration type from this URL. Try adding manually.");
207− setDetecting(false);
208− return;
209− }
210− const detected = bestDetection(results);
211− if (!detected) {
212− trackEvent("integration_detect_submitted", { success: false });
213− setError("Could not detect an integration type from this URL. Try adding manually.");
214− setDetecting(false);
215− return;
216− }
217− trackEvent("integration_detect_submitted", {
218− success: true,
219− detected_kind: detected.kind,
220− confidence: detected.confidence,
221− });
222− const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind] ?? detected.kind;
223− if (integrationPlugins.some((p) => p.key === pluginKey)) {
224− trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" });
225− closeAndReset();
226− void navigate({
227− to: "/{-$orgSlug}/integrations/add/$pluginKey",
228− params: { pluginKey },
229− search: { url: trimmed, namespace: detected.slug },
230− });
231− } else {
232− setError(`Detected integration type "${detected.kind}" but no plugin is available for it.`);
233− setDetecting(false);
234− }
235− }, [query, doDetect, navigate, integrationPlugins, closeAndReset]);
236−
237− return (
238− <Dialog
239− open={props.open}
240− onOpenChange={(open) => {
241− if (!open) closeAndReset();
242− else props.onOpenChange(open);
243− }}
244− >
245− <DialogContent className="sm:max-w-[560px]">
246− <DialogHeader>
247− <DialogTitle>Connect an integration</DialogTitle>
248− <DialogDescription>
249− Search the preset library, or paste a URL to auto-detect.
250− </DialogDescription>
251− </DialogHeader>
252−
253− <div className="flex min-w-0 flex-col gap-5">
254− <div className="flex flex-col gap-2">
255− <div className="flex gap-2">
256− <Input
257− type="text"
258− value={query}
259− onChange={(e) => {
260− setQuery((e.target as HTMLInputElement).value);
261− setError(null);
262− }}
263− onKeyDown={(e) => {
264− if (e.key === "Enter" && isUrl) void handleDetect();
265− }}
266− placeholder="Search or paste a URL…"
267− disabled={detecting}
268− className="flex-1"
269− />
270− {isUrl && (
271− <Button onClick={() => void handleDetect()} disabled={detecting || !query.trim()}>
272− {detecting ? "Detecting..." : "Detect"}
273− </Button>
274− )}
275− </div>
276− {error && <p className="text-xs text-destructive">{error}</p>}
277− </div>
278−
279− <div className="flex flex-col gap-2">
280− <p className="text-xs font-medium text-foreground/80">Or add manually</p>
281− <div className="flex flex-wrap gap-2">
282− {integrationPlugins.map((p) => (
283− <Link
284− key={p.key}
285− to="/{-$orgSlug}/integrations/add/$pluginKey"
286− params={{ pluginKey: p.key }}
287− onClick={() => {
288− trackEvent("integration_add_started", { plugin_key: p.key, via: "manual" });
289− closeAndReset();
290− }}
291− className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
292− >
293− {p.label}
294− </Link>
295− ))}
296− </div>
297− </div>
298−
299− <PresetGrid
300− plugins={integrationPlugins}
301− onPick={closeAndReset}
302− searchQuery={presetSearch}
303− />
304− </div>
305− </DialogContent>
306− </Dialog>
307− );
308−}
309−
310101 // ---------------------------------------------------------------------------
311102 // Empty state
312103 // ---------------------------------------------------------------------------
313104
314−function EmptyIntegrations(props: { onConnect: () => void }) {
105+function EmptyIntegrations() {
315106 return (
316107 <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">
317108 <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
321112 <p className="mb-5 text-[13px] text-muted-foreground/60">
322113 Connect an integration to start curating tools.
323114 </p>
324− <Button onClick={props.onConnect} size="sm" className="gap-1.5">
325− <PlusIcon className="size-4" />
326− Connect an integration
115+ <Button asChild size="sm" className="gap-1.5">
116+ <Link
117+ to="/{-$orgSlug}/integrations/browse"
118+ onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })}
119+ >
120+ <PlusIcon className="size-4" />
121+ Add an integration
122+ </Link>
327123 </Button>
328124 </div>
329125 );
330126 }
331127
332−// ---------------------------------------------------------------------------
333−// Preset grid (for inside the Connect dialog)
334−// ---------------------------------------------------------------------------
335−
336−type PresetEntry = {
337− preset: IntegrationPreset;
338− pluginKey: string;
339− pluginLabel: string;
340−};
341−
342−const CATALOG_KIND_LABEL: Record<CatalogKind, string> = {
343− mcp: "MCP",
344− openapi: "OpenAPI",
345− graphql: "GraphQL",
346−};
347−
348−function PresetGrid(props: {
349− plugins: readonly IntegrationPlugin[];
350− onPick: () => void;
351− /** Controlled filter query forwarded from the dialog's unified
352− * search/URL input. Empty string disables filtering. */
353− searchQuery?: string;
354−}) {
355− const navigate = useNavigate();
356− const allPresets = useMemo(() => {
357− const entries: PresetEntry[] = [];
358− for (const plugin of props.plugins) {
359− for (const preset of plugin.presets ?? []) {
360− entries.push({
361− preset,
362− pluginKey: plugin.key,
363− pluginLabel: plugin.label,
364− });
365− }
366− }
367− return entries;
368− }, [props.plugins]);
369−
370− const query = (props.searchQuery ?? "").trim();
371−
372− const filtered = useMemo(() => {
373− const q = query.toLowerCase();
374− if (q.length === 0) return allPresets;
375− return allPresets.filter(({ preset, pluginLabel }) => {
376− const corpus =
377− `${preset.name} ${preset.summary ?? ""} ${preset.family ?? ""} ${preset.specFormat ?? ""} ${pluginLabel}`.toLowerCase();
378− return corpus.includes(q);
379− });
380− }, [allPresets, query]);
381−
382− // Long-tail search over the public integrations.sh registry, shown under the
383− // curated presets. Domains a preset already covers stay preset-only.
384− const catalog = useCatalogSearch(query);
385− const excludeDomains = useMemo(() => presetDomains(props.plugins), [props.plugins]);
386− const availableKinds = useMemo(() => availableCatalogKinds(props.plugins), [props.plugins]);
387− const catalogEntries = useMemo(
388− () => filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds }),
389− [catalog.entries, excludeDomains, availableKinds],
390− );
391− const [resolvingDomain, setResolvingDomain] = useState<string | null>(null);
392− const [catalogError, setCatalogError] = useState<string | null>(null);
393−
394− const pickCatalogEntry = useCallback(
395− async (entry: CatalogSearchEntry) => {
396− if (resolvingDomain !== null) return;
397− setResolvingDomain(entry.domain);
398− setCatalogError(null);
399− const exit = await Effect.runPromiseExit(resolveConnectTarget(entry.domain, entry.kinds));
400− setResolvingDomain(null);
401− if (Exit.isFailure(exit)) {
402− setCatalogError(
403− `Couldn't load connect details for ${entry.domain}. Paste a URL above instead.`,
404− );
405− return;
406− }
407− const target = exit.value;
408− if (!target) {
409− setCatalogError(
410− `No connectable endpoint is on record for ${entry.domain}. Paste a URL above to detect one.`,
411− );
412− return;
413− }
414− trackEvent("integration_add_started", {
415− plugin_key: target.kind,
416− via: "catalog",
417− catalog_domain: entry.domain,
418− });
419− props.onPick();
420− void navigate({
421− to: "/{-$orgSlug}/integrations/add/$pluginKey",
422− params: { pluginKey: target.kind },
423− search: { url: target.url, ...(target.slug ? { namespace: target.slug } : {}) },
424− });
425− },
426− [navigate, props, resolvingDomain],
427− );
428−
429− const showCatalogSection = query.length > 0 && (catalogEntries.length > 0 || catalog.loading);
430−
431− if (allPresets.length === 0) return null;
432−
433− return (
434− <div className="flex min-w-0 flex-col gap-2">
435− <p className="text-xs font-medium text-foreground/80">Popular integrations</p>
436− <CardStack className="min-w-0">
437− {/* Fixed height keeps the dialog stable as the user filters; the
438− * inner area scrolls when the list overflows and shows an empty
439− * state when no presets match. */}
440− <CardStackContent className="h-64 overflow-y-auto">
441− {filtered.length === 0 && !showCatalogSection ? (
442− <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-6 text-center">
443− <p className="text-sm text-muted-foreground">No matching integrations</p>
444− <p className="text-xs text-muted-foreground/70">
445− Paste a URL above to auto-detect, or pick an integration type manually.
446− </p>
447− </div>
448− ) : (
449− filtered.map(({ preset, pluginKey, pluginLabel }) => {
450− const search: Record<string, string> = { preset: preset.id };
451− if (preset.url) search.url = preset.url;
452− return (
453− <CardStackEntry key={`${pluginKey}-${preset.id}`} asChild>
454− <Link
455− to="/{-$orgSlug}/integrations/add/$pluginKey"
456− params={{ pluginKey }}
457− search={search}
458− onClick={() => {
459− trackEvent("integration_add_started", {
460− plugin_key: pluginKey,
461− via: "preset",
462− preset_id: preset.id,
463− });
464− props.onPick();
465− }}
466− >
467− <CardStackEntryMedia>
468− <PresetIcon
469− {...(preset.icon ? { icon: preset.icon } : {})}
470− {...(preset.fallbackIcon ? { fallbackSrc: preset.fallbackIcon } : {})}
471− className="size-5 object-contain"
472− fallback={
473− <svg viewBox="0 0 16 16" className="size-3.5" fill="none">
474− <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />
475− </svg>
476− }
477− />
478− </CardStackEntryMedia>
479− <CardStackEntryContent>
480− <CardStackEntryTitle>{preset.name}</CardStackEntryTitle>
481− <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription>
482− </CardStackEntryContent>
483− <CardStackEntryActions>
484− <Badge variant="secondary">{pluginLabel}</Badge>
485− </CardStackEntryActions>
486− </Link>
487− </CardStackEntry>
488− );
489− })
490− )}
491− {showCatalogSection && (
492− <>
493− {catalogEntries.map((entry) => (
494− <CardStackEntry key={`catalog-${entry.domain}`} asChild>
495− <Button
496− type="button"
497− variant="ghost"
498− onClick={() => void pickCatalogEntry(entry)}
499− className="h-auto w-full justify-start rounded-none text-left font-normal whitespace-normal hover:bg-accent/40 dark:hover:bg-accent/40"
500− >
501− <CardStackEntryMedia>
502− <img
503− src={catalogLogoUrl(entry.domain, 10)}
504− alt=""
505− className="size-5 object-contain"
506− loading="lazy"
507− />
508− </CardStackEntryMedia>
509− <CardStackEntryContent>
510− <CardStackEntryTitle>{entry.domain}</CardStackEntryTitle>
511− <CardStackEntryDescription>{entry.description}</CardStackEntryDescription>
512− </CardStackEntryContent>
513− <CardStackEntryActions>
514− {resolvingDomain === entry.domain ? (
515− <span className="text-xs text-muted-foreground">Resolving…</span>
516− ) : (
517− entry.kinds.map((kind) => (
518− <Badge key={kind} variant="secondary">
519− {CATALOG_KIND_LABEL[kind]}
520− </Badge>
521− ))
522− )}
523− </CardStackEntryActions>
524− </Button>
525− </CardStackEntry>
526− ))}
527− {catalog.loading &&
528− catalogEntries.length === 0 &&
529− Array.from({ length: 3 }).map((_, i) => (
530− <div key={`catalog-skeleton-${i}`} className="flex items-center gap-3 px-4 py-3">
531− <Skeleton className="size-5 shrink-0 rounded-md" />
532− <div className="flex min-w-0 flex-1 flex-col gap-1.5">
533− <Skeleton className="h-3.5" style={{ width: `${35 + ((i * 13) % 25)}%` }} />
534− <Skeleton className="h-3" style={{ width: `${55 + ((i * 9) % 20)}%` }} />
535− </div>
536− <Skeleton className="h-5 w-14 rounded-full" />
537− </div>
538− ))}
539− </>
540− )}
541− </CardStackContent>
542− </CardStack>
543− {catalogError && <p className="text-xs text-destructive">{catalogError}</p>}
544− </div>
545− );
546−}
547−
548128 // ---------------------------------------------------------------------------
549129 // Integration grid — flat list of catalog integrations, click-through to detail
550130 // ---------------------------------------------------------------------------
packages/react/src/routes/integrations.add.$pluginKey.tsx+20−2
@@ -8,6 +8,14 @@ const SearchParams = Schema.toStandardSchemaV1( · 7 unmodified lines
88 url: Schema.optional(Schema.String),
99 preset: Schema.optional(Schema.String),
1010 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),
1119 }),
1220 );
1321
@@ -15,9 +23,19 @@ export const Route = createFileRoute("/{-$orgSlug}/integrations/add/$pluginKey") · 1 unmodified line
1523 validateSearch: SearchParams,
1624 component: () => {
1725 const { pluginKey } = Route.useParams();
18− const { url, preset, namespace } = Route.useSearch();
26+ const { url, preset, namespace, authHeader, authNote, authKind, specOverrides } =
27+ Route.useSearch();
1928 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+ />
2139 );
2240 },
2341 });
packages/react/src/routes/integrations.browse.tsx+7−0
@@ -0,0 +1,7 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+import { IntegrationBrowsePage } from "../pages/integration-browse";
4+
5+export const Route = createFileRoute("/{-$orgSlug}/integrations/browse")({
6+ component: IntegrationBrowsePage,
7+});
packages/react/src/routes/routeTree.gen.ts+22−0
@@ -18,6 +18,7 @@ import { Route as DotPoliciesRouteImport } from './policies' · 17 unmodified lines
1818 import { Route as DotArtifactsRouteImport } from './artifacts'
1919 import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'
2020 import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'
21+import { Route as DotIntegrationsDotbrowseRouteImport } from './integrations.browse'
2122 import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'
2223 import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug'
2324 import { Route as DotArtifactsDotartifactIdRouteImport } from './artifacts.$artifactId'
@@ -70,6 +71,12 @@ const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({ · 46 unmodified lines
7071 path: '/{-$orgSlug}/resume/$executionId',
7172 getParentRoute: () => rootRouteImport,
7273 } as any)
74+const DotIntegrationsDotbrowseRoute =
75+ DotIntegrationsDotbrowseRouteImport.update({
76+ id: '/{-$orgSlug}/integrations/browse',
77+ path: '/{-$orgSlug}/integrations/browse',
78+ getParentRoute: () => rootRouteImport,
79+ } as any)
7380 const DotIntegrationsDotnamespaceRoute =
7481 DotIntegrationsDotnamespaceRouteImport.update({
7582 id: '/{-$orgSlug}/integrations/$namespace',
@@ -112,6 +119,7 @@ export interface FileRoutesByFullPath { · 36 unmodified lines
112119 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute
113120 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
114121 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
122+ '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute
115123 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
116124 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
117125 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -128,6 +136,7 @@ export interface FileRoutesByTo { · 10 unmodified lines
128136 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute
129137 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
130138 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
139+ '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute
131140 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
132141 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
133142 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -145,6 +154,7 @@ export interface FileRoutesById { · 11 unmodified lines
145154 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute
146155 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
147156 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
157+ '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute
148158 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
149159 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
150160 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -163,6 +173,7 @@ export interface FileRouteTypes { · 12 unmodified lines
163173 | '/{-$orgSlug}/artifacts/$artifactId'
164174 | '/{-$orgSlug}/connect/$integrationSlug'
165175 | '/{-$orgSlug}/integrations/$namespace'
176+ | '/{-$orgSlug}/integrations/browse'
166177 | '/{-$orgSlug}/resume/$executionId'
167178 | '/{-$orgSlug}/toolkits/$toolkitSlug'
168179 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -179,6 +190,7 @@ export interface FileRouteTypes { · 10 unmodified lines
179190 | '/{-$orgSlug}/artifacts/$artifactId'
180191 | '/{-$orgSlug}/connect/$integrationSlug'
181192 | '/{-$orgSlug}/integrations/$namespace'
193+ | '/{-$orgSlug}/integrations/browse'
182194 | '/{-$orgSlug}/resume/$executionId'
183195 | '/{-$orgSlug}/toolkits/$toolkitSlug'
184196 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -195,6 +207,7 @@ export interface FileRouteTypes { · 10 unmodified lines
195207 | '/{-$orgSlug}/artifacts/$artifactId'
196208 | '/{-$orgSlug}/connect/$integrationSlug'
197209 | '/{-$orgSlug}/integrations/$namespace'
210+ | '/{-$orgSlug}/integrations/browse'
198211 | '/{-$orgSlug}/resume/$executionId'
199212 | '/{-$orgSlug}/toolkits/$toolkitSlug'
200213 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -211,6 +224,7 @@ export interface RootRouteChildren { · 10 unmodified lines
211224 DotIndexRoute: typeof DotIndexRoute
212225 DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute
213226 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute
227+ DotIntegrationsDotbrowseRoute: typeof DotIntegrationsDotbrowseRoute
214228 DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute
215229 DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute
216230 DotPluginsDotpluginIdDotsplatRoute: typeof DotPluginsDotpluginIdDotsplatRoute
@@ -281,6 +295,13 @@ declare module '@tanstack/react-router' { · 64 unmodified lines
281295 preLoaderRoute: typeof DotResumeDotexecutionIdRouteImport
282296 parentRoute: typeof rootRouteImport
283297 }
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+ }
284305 '/{-$orgSlug}/integrations/$namespace': {
285306 id: '/{-$orgSlug}/integrations/$namespace'
286307 path: '/{-$orgSlug}/integrations/$namespace'
@@ -353,6 +374,7 @@ const rootRouteChildren: RootRouteChildren = { · 66 unmodified lines
353374 DotIndexRoute: DotIndexRoute,
354375 DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute,
355376 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,
377+ DotIntegrationsDotbrowseRoute: DotIntegrationsDotbrowseRoute,
356378 DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute,
357379 DotIntegrationsDotaddDotpluginKeyRoute:
358380 DotIntegrationsDotaddDotpluginKeyRoute,
plans/kill-plugin-system.md+515−0
@@ -0,0 +1,515 @@
1+# Kill the plugin system — in-place migration plan
2+
3+Decision (Rhys, 2026-07-27): abandon the big-bang rework
4+(`UsefulSoftwareCo/executor-rework`, cloned read-only at
5+`.reference/executor-rework`); fix this repo in place over a sequence of PRs,
6+each keeping format/lint/typecheck/test/e2e green. Priority order: **data
7+models first**, then the plugin kill, then the concept-package (apps) model,
8+then the minimal SDK / platform split.
9+
10+RESEQUENCED (Rhys, 2026-07-28): **Phase 3 (apps/concept-package model) now
11+lands BEFORE Phase 2 (plugin kill), side by side with the protocol plugins.**
12+Rationale: the new model's tables are greenfield and additive — landing it
13+first means the legacy apps tables never need an in-place migration; the whole
14+model swaps underneath while openapi/mcp/graphql keep running as plugins.
15+Structurally sound because (a) the kernel + MCP host are already plugin-free,
16+so the new model's runtime never touched the registry, and (b) the apps plugin
17+already exercises every extension point, so the new model ships as the apps
18+plugin's new internals wearing the same costume. When 2.3 later converts the
19+four kinds, apps converts as a stable, proven model instead of mid-rewrite.
20+Phase 0 and 2.1 remain freely parallel — they don't touch apps.
21+
22+Hard line: side-by-side means new-apps-model alongside the _protocol plugins_,
23+never two app models with dual read paths inside the apps kind. Existing app
24+sources get a one-off migration to published-source form (enumerated
25+invariants, explicit approval, no permanent compatibility path). Accepted
26+cost: the new model's HTTP surface temporarily wears plugin-route ceremony
27+(HttpApiGroup + handler layers) that 2.2/2.6 later deletes — wiring, not
28+model. The authoring SDK (`defineApp`/`defineProvider`/`defineTool`) goes
29+public early; its boundaries take Standard Schema (StandardSchemaV1 &
30+StandardJSONSchemaV1), never Effect Schema, and Phase 4's vocabulary rules
31+(opaque owner refs, no "tenant") bind on it from day one.
32+
33+## Diagnosis (verified against the code)
34+
35+**The plugin system is a fiction.** `packages/core/sdk/src/plugin.ts` (~850
36+lines, ~20 optional hooks) pretends the plugin set is open, but:
37+
38+- `Integration.kind` _is_ the plugin id string; all hot paths dispatch via
39+ `runtimes.get(row.plugin_id)` (`executor.ts`).
40+- Core and React hardcode the closed set anyway: `migration-spec.ts` hardcodes
41+ plugin ids; `packages/react` has `KIND_TO_PLUGIN_KEY` maps and an
42+ `if (pluginId === "toolkits")` route branch.
43+- Nobody ever shipped a fourth protocol plugin, and the two provider plugins
44+ (google/microsoft) were already collapsed into openapi (#1366).
45+- Whole packages exist only to support the open set: `plugin-routes.ts`
46+ type-union machinery, `@executor-js/config`'s jsonc+jiti loader,
47+ `packages/core/vite-plugin`'s virtual client module. The jsonc plugin path is
48+ vestigial — no `executor.jsonc` exists anywhere in the repo, no docs mention
49+ it.
50+- Toolkits (~880 LoC of pure curation + the single `toolPolicyProvider` slot)
51+ and apps (~6.8k custom-tools gateway) are first-party capabilities wearing a
52+ plugin costume. Kernel packages and `packages/hosts/mcp` have zero plugin
53+ references — the indirection is not load-bearing for the real surfaces.
54+
55+**The data model is connection-first, not user-first**
56+(`packages/core/sdk/src/core-schema.ts`):
57+
58+- No user/subject table. `subject` is an opaque partition-key string smeared
59+ across owned tables; a user "exists" only if they have a connection row.
60+- Disconnect is a hard delete (`connectionsRemove`) — a signed-out user is
61+ bit-identical to one who never connected. "Which users are signed out" is
62+ structurally unanswerable.
63+- Every Executor is bound to one `{tenant, subject}` via `owner-policy.ts`;
64+ there is no cross-subject read path, so no admin view.
65+- Toolkit state lives in generic `plugin_storage` KV, not core tables.
66+- Cloud's real identity tables (WorkOS accounts/organizations) have no join to
67+ `subject`.
68+
69+**What to salvage from the rework** (its model docs are the valuable artifact;
70+its implementation ported only ~10 of 131 legacy e2e scenarios — that's why it
71+failed, and why this plan is incremental):
72+
73+- Apps-as-published-source: `defineApp`/`defineProvider`/`defineTool`/
74+ `defineToolGroup`, immutable content-hashed versions, manifest derived by
75+ executing the source, rollback = pointer move.
76+- Generators: OpenAPI/MCP specs generate the _same_ app source a human would
77+ write, with a re-verifiable `GeneratorClaim` (first-party vs third-party).
78+- Minimal SDK boundary: `createExecutor`/`createRemoteExecutor`, no Effect
79+ crossing the boundary, MCP exposure returns a standard MCP SDK server.
80+- Custody separation: secrets never on the wire; opaque owner refs.
81+
82+## Constraints discovered (do not relearn these)
83+
84+1. **Published packages make this semver-major.** `scripts/publish-packages.ts`
85+ publishes `@executor-js/{config,sdk}` and plugins
86+ `{example,file-secrets,graphql,keychain,mcp,onepassword,openapi}` — killing
87+ `definePlugin` is a breaking release for seven packages. Changesets +
88+ deprecation story required; `publish-packages.ts`, `pkg-pr-new.yml`,
89+ `check-changelog-stubs.ts`, and `examples/*` all have hardcoded lists.
90+2. **`plugin_id = 'google'` is a live persisted value with no runtime.** The
91+ provider-service-split migration skips orgs with missing spec blobs
92+ (`sqlite.test.ts` asserts survivors). The `googleDiscovery → "google"`
93+ entries in React are compensating, not dangling. The closed SourceType
94+ switch must map `"google"`/`"googleDiscovery"` → openapi or degraded rows
95+ become hard failures.
96+3. **FumaDB is not versioned in practice.** All hosts pin `version: "1.0.0"`;
97+ runtime bring-up is `ensureDrizzleRuntimeSchemaFromTables` (CREATE TABLE IF
98+ NOT EXISTS + additive nullable ALTERs). New tables/nullable columns are free
99+ on local/self-host/D1. **Cloud is the exception**: hand-edit
100+ `apps/cloud/src/db/executor-schema.ts` + drizzle-kit migration +
101+ `db:migrate:prod`; `db.schema.test.ts` enforces the mirror, so "forgot
102+ cloud" fails tests.
103+4. **93 e2e files import `composePluginApi`** purely to type the client. Keep
104+ it as a deprecated argument-ignoring shim returning the static
105+ `FullExecutorApi`; sweep the call sites mechanically in one final PR.
106+5. **Keep the `plugin_id` column name** (four tables + blob namespace format
107+ `o:<tenant>/<plugin_id>`). Renaming to `source_kind` is a cross-host
108+ migration for zero behavior. Retype on the read side only.
109+6. **URL contracts:** `/integrations/add/$pluginKey` is navigated directly by
110+ e2e; `/plugins/desktop-settings/` is asserted by the desktop-packaged suite
111+ and baked into a tool's returned URL. Keep shapes / add redirects.
112+7. **CI gaps:** PRs run cloud (4 shards) + selfhost e2e only; local/desktop
113+ suites gate push-to-main. PRs touching desktop-settings or the local loader
114+ need a manual local e2e run pre-merge.
115+8. **Toolkit selection filters _connections_, not just tools**
116+ (`connectionsList` goes through the active policy provider). Preserve when
117+ moving toolkits to core, or the toolkit e2e suites go red.
118+
119+## Phase 0 — declutter (3 PRs)
120+
121+- **0.1** Delete empty `packages/plugins/{google,microsoft}`; drop the unused
122+ `@executor-js/config` dep from openapi/mcp/graphql; move
123+ `provider-service-split` to `packages/migrations/` (keep package name — it's
124+ imported by three host data-migration registries). In the React maps, keep
125+ `google`/`googleDiscovery` → openapi with a comment citing constraint 2.
126+- **0.2** Bake google/microsoft catalogs + spec-format adapters in as openapi
127+ defaults; strip the re-threading from all four host configs. Gate:
128+ `provider-plugins-ui`, `google-health-checks`, `microsoft-*` scenarios.
129+- **0.3** Move desktop-settings (438-line Electron-IPC client, not a
130+ one-liner) to a first-class desktop-only route in `packages/app`; update the
131+ static tool's URL and the desktop-packaged e2e selector; delete the plugin.
132+
133+## Phase 1 — data model (4 PRs) — FIRST PRIORITY
134+
135+Design choices (least-invasive, verified feasible):
136+
137+- The new **subject table is `tenant`-scoped, not owner-scoped** — readable by
138+ any executor bound to the tenant with **zero policy changes** (the
139+ `integration` table already proves the pattern).
140+- Cross-subject connection reads: add `reach: "bound" | "tenant"` to
141+ `ExecutorOwnerPolicyContext` — widens `ownerVisibilityCondition` for reads
142+ only; `assertOwnerWritable`/`assertOwnerPatch` untouched, so admin reach can
143+ never write. Only two construction sites exist (`executor.ts`,
144+ `test-config.ts`).
145+- Identity stays with the hosts (WorkOS / Better Auth member lists already
146+ exist behind `AccountProvider.listMembers`); the subject table is the missing
147+ **join** between host identity and executor connections, not a new identity
148+ system. Tolerate `subject = null` (pure-org) and `subject = "local"`.
149+
150+PRs:
151+
152+- **1.1** Add tenant-scoped `subject` table (id, tenant, external_id,
153+ created_at, last_seen_at, status) to `coreTables` + cloud mirror + one
154+ drizzle migration. No readers yet.
155+- **1.2** Populate: upsert in `makeScopedExecutor`
156+ (`packages/core/api/src/server/scoped-executor.ts` — every HTTP request and
157+ MCP session on all four hosts passes through it) and at connection-create.
158+ Add to `purgeOrganizationData` + its `TENANT_TABLES` test.
159+- **1.3** `reach` mode + `admin.listSubjectsWithConnections`-shaped read on a
160+ derived handle. Policy test proving admin reach cannot write. Scope includes
161+ (customer ask, owner.com 2026-07-27): per-subject reads ("connections for
162+ subject X"), not just the aggregate list, and a privileged org-level API key
163+ (keys already carry `{accountId, organizationId}` —
164+ `apps/cloud/src/auth/api-keys.ts`) that resolves to the tenant-reach
165+ read-only handle so customers can inspect connections programmatically.
166+- **1.4** Connection lifecycle: nullable `status` column
167+ (active/disconnected/revoked/expired) plus `status_changed_at`; disconnect
168+ writes status instead of deleting. Wide blast radius — status predicate
169+ through `findConnectionRow`, `connectionsList`, credential resolution,
170+ `toolsList`, org purge; own e2e sweep (`connections-*`, `health-checks*`,
171+ `oauth-*`). PRIORITY RAISED (customer, 2026-07-28): the customer's
172+ block-startup-until-reconnect flow needs connected-then-disconnected to be
173+ distinguishable from never-connected, which hard-delete makes impossible
174+ today; expose status + statusChangedAt through the admin API when this
175+ lands.
176+
177+Toolkits data does NOT move in this phase (it moves with its reader in 2.4 —
178+moving it earlier means either rewriting the plugin storage layer twice or a
179+forbidden dual read path).
180+
181+### Framing: product view vs platform view (Rhys, 2026-07-27)
182+
183+The two-view model for consumer-facing surfaces, longer-term:
184+
185+- **Product view** — the bound, per-individual executor every request gets
186+ today (their connections, their tools). The default; unchanged.
187+- **Platform view** — an operator-level view attached alongside it: read-only,
188+ cross-user, explicitly reached for with elevated access (an escape hatch,
189+ never a mode product view drifts into). The `reach: "bound" | "tenant"`
190+ split in 1.3 is this model's first implementation; 1.5's admin API and the
191+ privileged key are its first surface. Later platform-view candidates (usage,
192+ policy overview, cross-user health) land on the same handle.
193+
194+Cloud wrinkle: cloud consumers like owner.com don't bring their own owner ids —
195+their users authenticate through our WorkOS, so `subject` is _our_ accountId
196+and the customer correlates on their side. The opaque-owner-ids-in-the-
197+customer's-id-space story fully applies only to embedded/API consumers; for
198+cloud, the platform view (+ `external_id` on the subject table) is what makes
199+the correlation tractable.
200+
201+### Fast path: owner.com admin-visibility ask (ships from Phase 1 alone)
202+
203+The owner.com ask (admin inspects users' connections; API key that can do the
204+same; available-vs-connected icon grid; direct connect links) needs only
205+1.1 → 1.2 → 1.3 plus a thin delivery layer — no dependency on Phase 0 or the
206+plugin kill. Delivery PRs on top of 1.3:
207+
208+- **1.5** Public admin API surface: `GET .../admin/users`,
209+ `GET .../admin/users/:externalId/connections` (integration slug + status +
210+ last_health only — never item_ids/oauth fields), gated on the privileged key
211+ / admin membership. Public paths say `users`, not `subjects`/`tenants` — see
212+ "Vocabulary" below. The available-vs-connected diff is computable client-side
213+ from this plus the existing integration catalog.
214+- **1.6** Connect deep links: stable
215+ `/connect/:integrationSlug`-shaped URL that lands the arriving user in the
216+ connect flow for that integration (auth-aware redirect). Uses integration
217+ slugs, not plugin routes, so it survives Phase 2.5 unchanged.
218+
219+ 1.4 (lifecycle status) upgrades the grid from binary connected/not to
220+ expired/disconnected states but is not a blocker for first ship.
221+
222+## Phase 2 — kill the registry (6 PRs)
223+
224+- **2.1** Remove dynamic loading: delete `load-plugins.ts` from
225+ `@executor-js/config` (keep the rest — jsonc integration config is a separate
226+ feature), drop `loadPluginsFromJsonc` from `apps/local`, delete
227+ `packages/core/vite-plugin`, replace `virtual:executor/plugins-client` with a
228+ static module in `packages/app` (5 `__root.tsx` importers + 3 ambient
229+ `.d.ts`), edit `turbo.json` `dev.dependsOn` + root `prepare`, fix
230+ `packages/app/vite.config.ts`'s dangling jsonc path. Breaking changeset.
231+- **2.2** Static route list: `composePluginApi` becomes the deprecated shim
232+ (constraint 4); hosts stop passing plugin tuples; delete
233+ `apps/{cloud,host-selfhost}/src/plugins.ts` (exist only to hold
234+ `PluginExtensionServices` typing).
235+- **2.3** The switch: closed internal `SourceType` interface implemented by
236+ openapi/graphql/mcp/**apps (current model, unchanged)** as plain libraries,
237+ replacing `runtimes.get`. All four kinds convert together — converting three
238+ and leaving apps on the plugin path means carrying dual dispatch; rewriting
239+ the apps model in the same PR is what killed the rework. Map
240+ `"google"`/`"googleDiscovery"` → openapi. `plugin_id` column name unchanged.
241+- **2.4** Toolkits to core: core tables + one-off migration out of
242+ `plugin_storage` (enumerated invariants, no dual path), `toolPolicyProvider`
243+ slot → native policy input (one call site, one implementation, one test),
244+ ordinary core routes, `activeToolkitSlug` becomes an executor option.
245+ Preserve the connection-visibility filter (constraint 8).
246+- **2.5** React: four named kind renderers; move plugin React code (~2.9k LoC,
247+ e.g. `AddMcpIntegration.tsx`) into `packages/react` — watch the peer-dep
248+ edge for cycles; delete `defineClientPlugin`, the plugins context, both
249+ `KIND_TO_PLUGIN_KEY` maps, and `/plugins/$pluginId/$` (with redirects for
250+ desktop-settings + toolkits). Keep `/integrations/add/$pluginKey` URL shape.
251+ Don't forget the second axis: `useSecretProviderPlugins` (secrets page).
252+- **2.6** Detach `CredentialProvider` from `PluginSpec` (the interface in
253+ `provider.ts` is already narrow — secret-store plugins keep it as their
254+ seam); delete `plugin.ts`, `plugin-routes.ts`, `plugin-example`, and the
255+ shim; mechanical sweep of the 93 e2e files; changesets for all seven
256+ published packages.
257+
258+## Phase 3 — custom = concept package (3–4 PRs) — NOW BEFORE PHASE 2
259+
260+(See RESEQUENCED note at top: lands side by side with the protocol plugins,
261+inside the apps plugin's existing costume; Phase 2 kills the registry
262+afterward, with apps converting in 2.3 as an already-stable model.)
263+
264+Port the rework's app model in place as the `apps` source kind's new model:
265+published source, manifest derived by execution, content-hashed immutable
266+versions, rollback = pointer move. New tables are additive greenfield (same
267+pattern as 1.1: coreTables + cloud mirror + migration, no readers first).
268+Existing app sources migrate one-off to published-source form — no dual model
269+within the apps kind. Then the OpenAPI/MCP generators + `GeneratorClaim` as an
270+optional on-ramp (spec → generated app source); GraphQL generator later.
271+Long-term option (explicitly out of scope): protocol kinds themselves become
272+generators and "custom" is the only kind.
273+
274+### Feasibility findings (two deep read-throughs, 2026-07-28)
275+
276+**Current plugin** (~7.6k LoC): only ~2.65k encodes the old model (store
277+collections, sync state machine, publish/discover/descriptor, routes,
278+console client). ~2.6k is model-agnostic and survives verbatim: all three
279+sandbox executors, the bundler stack, bindings/bridge, authoring +
280+standard-schema, conformance. ~1.9k (hand-rolled git client + source
281+fetchers) hangs on the publish-input decision below. Storage is entirely
282+generic `plugin_storage` rows + content-addressed blobs — zero apps-specific
283+tables or migrations exist, so new tables are pure greenfield. Cloud prod is
284+git-source-only; **source text is never persisted today (only compiled
285+bundles)**, so the one-off migration re-fetches source from the recorded git
286+URLs (fetcher exists). Load-bearing invariants to preserve: tool addresses
287+`tools.<app>.org.published.<name>` + the `<fileSlug>__<key>` naming rule,
288+app-slug↔integration-slug 1:1 guard, publish CAS
289+(`AppPublishConflictError`), tombstone-not-delete, token-in-provider-item
290+(never in the source row). Safety net: `custom-tools.test.ts` (534-line
291+cloud+selfhost console loop), local directory-picker, packed-CLI, desktop
292+e2e, ~2.9k LoC in-package tests. Pre-existing gaps flagged: R2 blobs never
293+GC'd and org purge misses them; cloud silently falls back to in-process
294+executor without the worker loader; host-cloudflare doesn't register apps.
295+
296+**Rework model** (portable tiers): T1 model proper ~3.9k LoC (authoring SDK,
297+publish-by-execution, projection, generator verification, storage port,
298+content hashing); T2 generators ~3.2k (fromOpenAPI 1k — complete; fromMCP
299+0.6k — computed group with real streamable-HTTP client; json-schema engine;
300+byte-for-byte GeneratorClaim verification; render endpoint); T4 host/registry
301+~2.9k explicitly skipped. Authoring is Standard Schema
302+(StandardSchemaV1 & StandardJSONSchemaV1) with raw-JSON-Schema literals as a
303+typed first-class alternative — matches our rule. Effect beta.93→beta.59
304+delta is small (ConstraintDecoder → one-line fix; hand-roll
305+StandardJSONSchemaV1 ~10 lines; reuse core sha256Hex). NOT portable as-is:
306+rework `storage-db.ts` (write a new adapter over our tables against the
307+~90-line Storage port interface) and `engine.ts` (extract only ~700 LoC of
308+model-owned logic: computed-surface cache + requirement resolution +
309+runTool). Known rework gaps to fix at port time, not inherit: derive-time
310+execution is unsandboxed in-process (`code-executor.ts:23` records it) — our
311+port MUST run publish-time evaluation in the existing sandbox `collect` path
312+from day one; fan-in computed groups unsupported; OAuth discovery
313+unsupported.
314+
315+**Semantic deltas — decided by Rhys before 3.1 specs:**
316+
317+1. **Handler credential contract (the deep one).** Current: handlers get an
318+ opaque callable `AppIntegrationClient` proxy that re-enters
319+ `ctx.execute` — app code never sees credentials, can only call other
320+ executor tools. Rework (D49): engine resolves credentials once and hands
321+ the handler **credential fields as data** (`ResolvedConnection.fields`,
322+ e.g. access_token) for direct HTTP — that's what makes generators
323+ possible (generated code does raw fetch with auth) and what
324+ `defineProvider` exists for. Not either/or in principle (a tool-call
325+ bridge can coexist as a second requirement kind later), but the ported
326+ model's primary contract is fields-as-data per the rework.
327+2. **Publish input.** Rework publish takes `SourceFiles` (push-based).
328+ Decision: keep git/local-directory as _fetchers that produce
329+ SourceFiles_ (preserves prod sources, the e2e suite, and the migration
330+ path; git-client survives), with direct push publish added as the new
331+ API. Delete-git-later stays open.
332+3. **App identity** moves from caller-supplied slug to the `defineApp`
333+ export inside the source (rework shape).
334+4. **Versioning**: content hash of source IS the version identity; the
335+ caller-supplied `sourceRef` + descriptor v6 die with the old model.
336+5. **Ownership**: app/version rows carry `space` and NO owner — spaces
337+ curate apps, principals own custody (connections stay on the legacy
338+ owner model in this phase; the opaque-OwnerRef swap is Phase 4).
339+
340+### Phase 3 PR sequence (revised)
341+
342+- **3.1** Port the model core as internal packages, no readers: authoring
343+ SDK + runtime brands, publish pipeline (evaluation routed through the
344+ sandbox `collect` seam), Storage port + adapter, greenfield tables
345+ (`app`, `app_version`, `computed_surface` — space column, content-hash
346+ identity, `current` pointer) in coreTables + cloud mirror + migration.
347+ Ported rework tests come along (authoring-boundary, tool-groups,
348+ publish).
349+- **3.2** Swap the apps plugin's internals onto the new model behind the
350+ same costume: resolveTools/projectToolSchema/invokeTool read the new
351+ tables; publish endpoint (`POST` source files); setCurrent/rollback +
352+ versions listing routes; git/local-dir sync becomes fetch→publish.
353+ Tool addresses and slug guards preserved; old collections
354+ (`apps_descriptors`/`apps_tools`/`apps_sources`) still written by
355+ NOTHING — one-off migration converts existing sources (re-fetch from
356+ git), then the collections are dead. e2e: full existing custom-tools
357+ suite green + new version-history/rollback scenario.
358+- **3.3** Console UI for the new model (sources → apps with versions,
359+ publish status, rollback) replacing the ~1.3k LoC old client/panels.
360+- **3.4** Generators: fromOpenAPI + fromMCP + GeneratorClaim verification +
361+ render endpoint, surfaced as the "add from spec/server" on-ramp.
362+ (Vendors google/microsoft conversion ~1.15k LoC ports later with 0.2.)
363+
364+## Spaces: the rework's tenancy primitive (added Rhys, 2026-07-28)
365+
366+The rework's SDK has exactly one tenancy concept: `SpaceId`
367+(`.reference/executor-rework/docs/TENANCY.md`, `docs/MODEL.md`). Adopt it as
368+the end-state tenancy model here. The shape:
369+
370+- **Flat, no hierarchy, no default.** No parent, no inheritance, no
371+ distinguished rows (rework D39/D44b). Sharing is an explicit act, never a
372+ tree walk (R2 — re-ruled twice, do not relitigate). Every scoped row
373+ carries a `space` column; every space-scoped route carries the space in
374+ the URL path, never ambient context.
375+- **No org/project/team/user below the deployment edge.** The engine answers
376+ "what is in this space"; the deployment answers "who is this and which
377+ spaces may they touch" through named seams (`authorizeBrowserResource`,
378+ `dashboardSpacesForPrincipal`, MCP space resolver). Membership is
379+ authorized LIVE per request at the edge — projection may be cached,
380+ authorization never.
381+- **Deployment mappings:** cloud = one WorkOS org mirrors lazily to one
382+ space (mirror row `workosOrgId → space` is also the billing join — Autumn
383+ keys on workosOrgId, never a space); self-host = one instance, one
384+ found-or-created "Default" space, Better Auth org repurposed as the
385+ instance membership table; local/desktop = one "local" space, one static
386+ principal, single-tenancy declared via explicit sentinel, not defaulted.
387+- **Spaces curate, principals own.** Custody (secrets, connections'
388+ secret-bearing halves) belongs to principals via opaque `OwnerRef` +
389+ per-request `Reach` (R3); spaces curate apps and hold rows. Publishing an
390+ app into a space IS its presence there — no installation rows, no
391+ bindings; cross-space arrival (sharing/registry) is a future share-edge
392+ row, not golden-path ceremony (D45/D42).
393+- Cross-org OAuth callbacks complete into the _initiating_ org's space; the
394+ org header is a routing hint, not an authorization statement (R7).
395+
396+**DECIDED (Rhys, 2026-07-28): spaces land in the CORE PRODUCT NOW — Phase S,
397+before the apps port.** Apps then publishes into real spaces from birth.
398+
399+**Mapping onto this repo — the no-migration trick:** today's `tenant` column
400+is de facto the space — cloud writes the WorkOS org id there, self-host/local
401+write one constant. So: keep the persisted `tenant` column name (same rule as
402+`plugin_id`), and declare that **its value IS the space id**. Each org's
403+first space is grandfathered with `spaceId = <historical tenant value>` (the
404+org id); NEW spaces mint fresh ids written into the same column. The org→
405+space mirror table records which space ids belong to which org (and carries
406+the billing join — Autumn keys on workosOrgId, never a space). Existing rows
407+never move; multi-space needs no schema change on scoped tables. "Space" is
408+contract vocabulary; "tenant" retreats to cloud-internal (see Vocabulary).
409+
410+### Phase S — spaces in core (before apps)
411+
412+- **S1 — space table + boot ensure (no readers).** Greenfield `space` table
413+ (id, name, created_at) in coreTables + cloud mirror + migration, plus the
414+ cloud `org→space` mirror table. Boot/first-contact ensures the degenerate
415+ space per deployment: cloud lazily on first authenticated org contact
416+ (grandfathered id = org id), self-host "Default", local "local". Same
417+ no-readers pattern as PR 1.1.
418+- **S2 — space threading.** `SpaceId` brand in the SDK; the scoped-executor
419+ seam resolves request → space id (today: identical to tenant) and stamps
420+ it; reads interpret `tenant` columns as space ids. No behavior change —
421+ this is the retype + vocabulary PR. `reach: "tenant"` reads as
422+ "space-wide reach" from here on.
423+- **S3 — multi-space on cloud + product surface.** Create/rename space,
424+ space switcher in the console, membership = org membership (all spaces of
425+ the org for now), org→N mirror rows, space-scoped catalog/connection
426+ views (rows follow automatically — they're keyed by the space id in the
427+ tenant column). Billing stays org-keyed via the mirror. e2e: two spaces
428+ in one org, isolation of catalogs/connections between them, switcher.
429+- **S4 — sharing model on spaces.** Connect-time "share with space" writes
430+ the space's shared owner-ref (replaces the org tier semantically; the
431+ persisted `owner: "org"|"user"` column keeps its name, read-side
432+ reinterpretation only). Edge computes reach as
433+ [principal, sharedRef(space) for each member space]. This is the bridge
434+ to Phase 4's full owner-set reach.
435+- **Later (not scheduled): local multi-space, file-backed.** Each local
436+ space is its own store file (portable — copy/backup/sync/check in
437+ alongside a repo); the daemon opens one storage handle per space and
438+ routes by space id; the engine still sees flat SpaceIds. Custody never
439+ enters the space file — secrets stay in the principal-keyed provider
440+ (keychain), so a copied file carries connection metadata, never tokens.
441+- Phase 4 completes the picture: space-in-path routes,
442+ `dashboardSpacesForPrincipal`-style seams, reach as an explicit owner
443+ set.
444+
445+Apps (Phase 3) then lands after S1/S2 — app rows carry a real `space` FK
446+from birth; S3/S4 can proceed in parallel with the apps port.
447+
448+Interaction with shipped Phase 1 work: the subject table and platform view
449+are edge/deployment concepts (who exists in this identity world, what may
450+an operator read) — they sit above spaces and survive unchanged; `reach:
451+"tenant"` becomes "space-wide reach" vocabulary-wise at Phase 4.
452+
453+## Vocabulary: retire "tenant" from consumer-facing surfaces (Rhys, 2026-07-27)
454+
455+Direction: the SDK should be agnostic to the consumer's data-model shape, the
456+way Composio's is — consumers pass an opaque user/owner id string and hang
457+whatever hierarchy they have (user, team, workspace) off it; the SDK never
458+models their org chart. The rework already designed this (`OwnerRef` opaque
459+brand, `Reach = {owners}`, deployments translate identity at the edge) — adopt
460+its vocabulary, not its schedule. "Tenant" remains a real _internal_ concept
461+(which Executor-cloud org owns the rows) but must not leak into surfaces
462+consumers touch.
463+
464+Rules, effective immediately:
465+
466+- Persisted column names (`tenant`, `subject`, `owner`) stay — renaming
467+ storage is a cross-host migration for zero behavior (same rule as
468+ `plugin_id`).
469+- Internal policy code (`reach: "tenant"`, `ExecutorOwnerPolicyContext`) may
470+ keep the words.
471+- One contract noun: **`owner`** — the opaque ref naming who a connection
472+ belongs to (end-user, team, workspace, or the org itself; the rework's
473+ `OwnerRef`). "User" cannot be the contract noun because org-shared
474+ connections are a load-bearing feature an end-user word can't cover.
475+- **"user" is presentation vocabulary only**, for surfaces whose rows genuinely
476+ are the customer's end-users — the Phase 1.5 admin API says `users` because
477+ the subject table is populated from authenticated principals.
478+- Hazard: the persisted `owner` column keeps its old meaning (the
479+ `"org" | "user"` tier discriminator), while Phase 4 introduces the opaque
480+ ref as a distinctly named type (`OwnerRef`). Same word, two meanings,
481+ separated by layer — say so in policy-layer comments.
482+- The full swap (opaque owner refs at the SDK boundary, tenant retreating to a
483+ cloud-internal detail) lands with the Phase 4 minimal SDK.
484+
485+## Phase 4 — minimal SDK + platform/product split
486+
487+New consumer boundary modeled on the rework: `createExecutor` /
488+`createRemoteExecutor`, no Effect at the boundary, `tools.list/execute` +
489+connections + the new users/admin surface; policies/toolkits become optional
490+layers consumers opt into, not baked opinions (Composio-style minimalism).
491+
492+Authorization end-state (Rhys, 2026-07-27): the engine enforces scope
493+mechanically but never decides policy — the host computes, per request, the
494+set of owners the request may read (the rework's `Reach = { owners }`), and
495+the storage predicate is just `row.owner ∈ reach.owners`. The 1.3
496+`reach: "bound" | "tenant"` enum is a stepping stone and is replaced by this
497+set shape here: "bound" = a reach of one, "tenant" = all owners (wildcard so
498+cloud needn't enumerate), and in-between cases (team views, support sessions,
499+subset-restricted org keys) are just different sets. Writes stay hard-pinned
500+to a single owner; only read reach generalizes. Trust model becomes a
501+per-deployment choice: a trusted backend with a server key gets tenant-wide
502+reach and layers its own authorization (Composio-style); an end-user session
503+gets a reach of one. The enum-to-set swap is contained: the policy context is
504+constructed in exactly two places.
505+Start the platform/product package separation _during_ 2.3–2.6 (each PR moves
506+one thing to its final home) rather than as a big bang; the SDK boundary
507+itself lands last.
508+
509+## Bookkeeping
510+
511+- Create `MISTAKES.md` / `DESIRES.md` / `LEARNINGS.md` (required by AGENTS.md,
512+ currently missing) with the first entries from this migration.
513+- `apps/local/src/db/executor-schema.ts` is already stale vs `coreTables`
514+ (missing several columns); its "keep in sync" comment is untrustworthy —
515+ runtime schema comes from the ensure path, not this file.