diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index d13911093..d9641402b 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteIm import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' @@ -115,6 +116,14 @@ const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({ path: '/{-$orgSlug}/resume/$executionId', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update( + { + id: '/{-$orgSlug}/integrations/browse', + path: '/{-$orgSlug}/integrations/browse', + getParentRoute: () => rootRouteImport, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( { @@ -172,6 +181,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -194,6 +204,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -217,6 +228,7 @@ export interface FileRoutesById { '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -241,6 +253,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/billing/plans' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -263,6 +276,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/billing/plans' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -285,6 +299,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/billing_/plans' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -307,6 +322,7 @@ export interface RootRouteChildren { Billing_DotplansRoute: typeof Billing_DotplansRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute } @@ -418,6 +434,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResumeDotexecutionIdRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/integrations/browse': { + id: '/{-$orgSlug}/integrations/browse' + path: '/{-$orgSlug}/integrations/browse' + fullPath: '/{-$orgSlug}/integrations/browse' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/integrations/$namespace': { id: '/{-$orgSlug}/integrations/$namespace' path: '/{-$orgSlug}/integrations/$namespace' @@ -511,6 +534,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute, ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute, @@ -520,11 +545,15 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' + import type { startInstance } from './start.ts' + declare module '@tanstack/react-start' { interface Register { ssr: true + router: Awaited> + config: Awaited> } } diff --git a/apps/host-cloudflare/web/routeTree.gen.ts b/apps/host-cloudflare/web/routeTree.gen.ts index 2acc6bbf2..8de64af12 100644 --- a/apps/host-cloudflare/web/routeTree.gen.ts +++ b/apps/host-cloudflare/web/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' @@ -76,6 +77,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update( + { + id: '/{-$orgSlug}/integrations/browse', + path: '/{-$orgSlug}/integrations/browse', + getParentRoute: () => rootRouteImport, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( { @@ -128,6 +137,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -143,6 +153,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -159,6 +170,7 @@ export interface FileRoutesById { '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -176,6 +188,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -191,6 +204,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -206,6 +220,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -221,6 +236,7 @@ export interface RootRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute @@ -284,6 +300,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/integrations/browse': { + id: '/{-$orgSlug}/integrations/browse' + path: '/{-$orgSlug}/integrations/browse' + fullPath: '/{-$orgSlug}/integrations/browse' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/integrations/$namespace': { id: '/{-$orgSlug}/integrations/$namespace' path: '/{-$orgSlug}/integrations/$namespace' @@ -369,6 +392,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index e2d2df624..4d09b6315 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as AdminRouteImport } from './routes/app/admin' import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' @@ -101,6 +102,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update( + { + id: '/{-$orgSlug}/integrations/browse', + path: '/{-$orgSlug}/integrations/browse', + getParentRoute: () => rootRouteImport, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( { @@ -157,6 +166,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -176,6 +186,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -196,6 +207,7 @@ export interface FileRoutesById { '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -217,6 +229,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -236,6 +249,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -255,6 +269,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -274,6 +289,7 @@ export interface RootRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute @@ -365,6 +381,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/integrations/browse': { + id: '/{-$orgSlug}/integrations/browse' + path: '/{-$orgSlug}/integrations/browse' + fullPath: '/{-$orgSlug}/integrations/browse' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/integrations/$namespace': { id: '/{-$orgSlug}/integrations/$namespace' path: '/{-$orgSlug}/integrations/$namespace' @@ -455,6 +478,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: diff --git a/apps/local/package.json b/apps/local/package.json index b2d9c2bbb..9dfff05d7 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -10,7 +10,7 @@ "scripts": { "dev": "bun run dev:proxy && bun run dev:vite", "dev:proxy": "portless proxy start --multiplex --port 1355 || true", - "dev:vite": "EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name executor-local bunx --bun vite dev", + "dev:vite": "EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name \"executor-local-$(basename \"$(cd ../.. && pwd)\")\" bunx --bun vite dev", "build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build", "start": "bun run src/serve.ts", "db:generate": "drizzle-kit generate", diff --git a/bun.lock b/bun.lock index 43add4725..da3fa0c3a 100644 --- a/bun.lock +++ b/bun.lock @@ -862,6 +862,30 @@ "vitest": "catalog:", }, }, + "packages/onboarding-demo": { + "name": "@executor-js/onboarding-demo", + "version": "0.0.0", + "dependencies": { + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/react": "workspace:*", + "@executor-js/sdk": "workspace:*", + "lucide-react": "^1.7.0", + "react": "catalog:", + "react-dom": "catalog:", + "sonner": "^2.0.7", + "tldts": "^7.0.28", + }, + "devDependencies": { + "@tailwindcss/vite": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + }, + }, "packages/plugins/desktop-settings": { "name": "@executor-js/plugin-desktop-settings", "version": "1.6.3", @@ -1807,6 +1831,8 @@ "@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=="], + "@executor-js/onboarding-demo": ["@executor-js/onboarding-demo@workspace:packages/onboarding-demo"], + "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"], "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"], diff --git a/e2e/cloud/frontend-error-reporting.test.ts b/e2e/cloud/frontend-error-reporting.test.ts index cfaa3d19e..5c9b73128 100644 --- a/e2e/cloud/frontend-error-reporting.test.ts +++ b/e2e/cloud/frontend-error-reporting.test.ts @@ -73,7 +73,9 @@ scenario( await step("Open the integrations console", async () => { await visit(page, "/integrations"); - await page.getByRole("button", { name: "Connect" }).first().waitFor(); + // The connect dialog became the full-page picker; the header's Add + // link is the page's stable loaded-signal now. + await page.getByRole("link", { name: "Add integration" }).first().waitFor(); }); let faulted = 0; diff --git a/e2e/scenarios/auth-methods-ui.test.ts b/e2e/scenarios/auth-methods-ui.test.ts index 224dd11f2..9d2646347 100644 --- a/e2e/scenarios/auth-methods-ui.test.ts +++ b/e2e/scenarios/auth-methods-ui.test.ts @@ -46,7 +46,7 @@ scenario( }); await step("The probe seeded the detected method", async () => { - await page.getByText("Method 1 · Detected").waitFor(); + await page.getByText("No authentication · Detected").waitFor(); }); await step("Declare an API key method alongside it", async () => { diff --git a/e2e/scenarios/first-party-oauth.test.ts b/e2e/scenarios/first-party-oauth.test.ts index e11984aa3..bee0c78da 100644 --- a/e2e/scenarios/first-party-oauth.test.ts +++ b/e2e/scenarios/first-party-oauth.test.ts @@ -26,7 +26,7 @@ import { import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; -import { clickToReveal, visit } from "../src/surfaces/browser"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -181,35 +181,10 @@ scenario( const client = yield* makeApiClient(api, identity); yield* browser.session(identity, async ({ page, step }) => { - await step("Open the Google integration catalog", async () => { - await visit(page, "/integrations"); - const dialog = page.getByRole("dialog", { name: "Connect an integration" }); - await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); + await step("Open the integrations registry browser", async () => { + await visit(page, "/integrations/browse"); + await page.getByPlaceholder(/Search integrations, or paste a URL/).waitFor(); }); - - const services = [ - "Google Calendar", - "Google Meet", - "Gmail", - "Google Sheets", - "Google Drive", - "Google Docs", - "Google Slides", - "Google Forms", - "Google Tasks", - "Google People", - "Google Photos Library", - "Google Photos Picker", - "Google Search Console", - ] as const; - const dialog = page.getByRole("dialog", { name: "Connect an integration" }); - const search = dialog.getByPlaceholder(/Search or paste a URL/); - for (const service of services) { - await step(`${service} is available to connect`, async () => { - await search.fill(service); - await dialog.getByRole("link", { name: new RegExp(`^${service}\\b`) }).waitFor(); - }); - } }); // The Executor-owned Google app is withheld from every listing: it is no diff --git a/e2e/scenarios/google-disabled-api.test.ts b/e2e/scenarios/google-disabled-api.test.ts index ccd18abdd..af3084e47 100644 --- a/e2e/scenarios/google-disabled-api.test.ts +++ b/e2e/scenarios/google-disabled-api.test.ts @@ -117,24 +117,21 @@ const createGoogleEmulator = Effect.gen(function* () { const addGooglePresetFromCatalog = ( browser: BrowserSurface, identity: Identity, + presetId: string, presetName: string, slug: string, ) => browser.session(identity, async ({ page, step }) => { - await step(`Open ${presetName} from the connect catalog`, async () => { - await page.goto("/integrations", { waitUntil: "networkidle" }); - await page - .getByRole("button", { name: /Connect/ }) - .first() - .click(); - const dialog = page.getByRole("dialog", { name: "Connect an integration" }); - await dialog.waitFor(); - await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); - await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click(); + await step(`Open the ${presetName} preset's add flow`, async () => { + // The connect dialog became the full-page picker, and registry-listed + // provider presets no longer render cards there — the preset deep link + // is the stable route to a preset-configured add flow. + await page.goto(`/integrations/add/openapi?preset=${presetId}`, { + waitUntil: "networkidle", + }); }); await step(`Add the ${presetName} integration`, async () => { - await page.waitForURL(/\/integrations\/add\/openapi/); await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); const button = page.getByRole("button", { name: "Add integration" }); await button.waitFor({ timeout: 120_000 }); @@ -218,7 +215,7 @@ scenario( yield* Effect.ensuring( Effect.gen(function* () { - yield* addGooglePresetFromCatalog(browser, identity, "Gmail", String(slug)); + yield* addGooglePresetFromCatalog(browser, identity, "google-gmail", "Gmail", String(slug)); const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); expect(stored?.operation, "the Gmail preset declares its labels probe").toBe( diff --git a/e2e/scenarios/google-health-checks.test.ts b/e2e/scenarios/google-health-checks.test.ts index 8cdb602a1..507699adb 100644 --- a/e2e/scenarios/google-health-checks.test.ts +++ b/e2e/scenarios/google-health-checks.test.ts @@ -17,13 +17,15 @@ import { createEmulatorInstance } from "../src/emulator-instance"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; import type { Identity, Target as TargetShape } from "../src/target"; -import { type BrowserSurface, clickToReveal, visit } from "../src/surfaces/browser"; +import { type BrowserSurface, visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2"); const CONNECTION = ConnectionName.make("main"); const GOOGLE_EMULATOR_ACCOUNT_EMAIL = "testuser@gmail.com"; +const googleDiscoveryUrl = (service: string, version: string) => + `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`; const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; @@ -79,19 +81,18 @@ const createGoogleEmulator = Effect.gen(function* () { return { client, baseUrl }; }); -const addGooglePresetFromCatalog = ( +const addGooglePresetFromDirectUrl = ( browser: BrowserSurface, identity: Identity, presetName: string, + presetId: string, + presetUrl: string, slug: string, ) => browser.session(identity, async ({ page, step }) => { - await step(`Open ${presetName} from the connect catalog`, async () => { - await visit(page, "/integrations"); - const dialog = page.getByRole("dialog", { name: "Connect an integration" }); - await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); - await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); - await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click(); + await step(`Open the ${presetName} preset add flow directly`, async () => { + const search = new URLSearchParams({ preset: presetId, url: presetUrl }); + await visit(page, `/integrations/add/openapi?${search}`); }); await step(`Add the ${presetName} integration`, async () => { @@ -201,6 +202,8 @@ scenario( const rows = [ { presetName: "Google Calendar", + presetId: "google-calendar", + presetUrl: googleDiscoveryUrl("calendar", "v3"), slug: IntegrationSlug.make("google_calendar"), oauthClient: OAuthClientSlug.make(unique("google_calendar_oauth")), expectedHealthOperation: "calendar.calendarList.list", @@ -209,6 +212,8 @@ scenario( }, { presetName: "Gmail", + presetId: "google-gmail", + presetUrl: googleDiscoveryUrl("gmail", "v1"), slug: IntegrationSlug.make("google_gmail"), oauthClient: OAuthClientSlug.make(unique("google_gmail_oauth")), expectedHealthOperation: "gmail.users.labels.list", @@ -220,7 +225,14 @@ scenario( yield* Effect.ensuring( Effect.gen(function* () { for (const row of rows) { - yield* addGooglePresetFromCatalog(browser, identity, row.presetName, String(row.slug)); + yield* addGooglePresetFromDirectUrl( + browser, + identity, + row.presetName, + row.presetId, + row.presetUrl, + String(row.slug), + ); const stored = yield* client.integrations.healthCheckGet({ params: { slug: row.slug } }); expect(stored?.operation, `${row.presetName} stored health check`).toBe( @@ -324,7 +336,14 @@ scenario( yield* Effect.ensuring( Effect.gen(function* () { - yield* addGooglePresetFromCatalog(browser, identity, "Google Sheets", String(slug)); + yield* addGooglePresetFromDirectUrl( + browser, + identity, + "Google Sheets", + "google-sheets", + googleDiscoveryUrl("sheets", "v4"), + String(slug), + ); const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); expect(stored, "Google Sheets catalog preset declares no health check").toBeNull(); diff --git a/e2e/scenarios/google-photos-preset-ui.test.ts b/e2e/scenarios/google-photos-preset-ui.test.ts index 463f4e8bd..9cc8e83a6 100644 --- a/e2e/scenarios/google-photos-preset-ui.test.ts +++ b/e2e/scenarios/google-photos-preset-ui.test.ts @@ -3,10 +3,15 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; -import { clickToReveal, visit } from "../src/surfaces/browser"; +import { visit } from "../src/surfaces/browser"; +const librarySpecUrl = "https://integrations.sh/specs/google/google-photos-library.json"; +const pickerSpecUrl = "https://integrations.sh/specs/google/google-photos-picker.json"; + +// Photos Library and Photos Picker are independent registry products. The +// picker must preserve that distinction all the way into the selected add flow. scenario( - "Google Photos: separated catalog presets open a Photos service add flow", + "Google Photos products are separate registry cards with separate add flows", {}, Effect.gen(function* () { const target = yield* Target; @@ -14,30 +19,100 @@ scenario( const identity = yield* target.newIdentity(); yield* browser.session(identity, async ({ page, step }) => { - await step( - "Find the separated Google Photos presets from the integrations picker", - async () => { - await visit(page, "/integrations"); - const dialog = page.getByRole("dialog", { name: "Connect an integration" }); - await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); - await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos"); - await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).waitFor(); - await dialog.getByRole("link", { name: /^Google Photos Picker\b/ }).waitFor(); - }, - ); + await step("Stub the two Google Photos registry products", async () => { + await page.route("https://integrations.sh/api/search*", (route) => + route.fulfill({ + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + json: { + results: [ + { + domain: "photos.google.com", + name: "Google Photos Library", + description: "Create and manage app-created photos.", + kinds: ["openapi"], + url: "https://integrations.sh/photos.google.com/", + }, + { + domain: "photos.google.com", + name: "Google Photos Picker", + description: "Let people select photos for an app.", + kinds: ["openapi"], + url: "https://integrations.sh/photos.google.com/", + }, + ], + }, + }), + ); + await page.route("https://integrations.sh/api/photos.google.com/surface", (route) => + route.fulfill({ + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + json: { + version: 3, + domain: "photos.google.com", + surfaces: [ + { type: "http", slug: "google-photos-library", spec: librarySpecUrl }, + { type: "http", slug: "google-photos-picker", spec: pickerSpecUrl }, + ], + }, + }), + ); + }); + + await step("Search for Photos and see two separate product cards", async () => { + await visit(page, "/integrations/browse"); + await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("photos"); + const cards = page.getByTestId("catalog-photos.google.com-openapi"); + await cards.filter({ hasText: "Google Photos Library API" }).waitFor(); + await cards.filter({ hasText: "Google Photos Picker API" }).waitFor(); + }); - await step("Open the Google Photos Library add flow", async () => { - const dialog = page.getByRole("dialog", { name: "Connect an integration" }); - await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).click(); - await page.waitForURL(/\/integrations\/add\/openapi/); - await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); + await step("Add Photos Library in place; the Picker card is untouched", async () => { + await page + .getByTestId("catalog-photos.google.com-openapi") + .filter({ hasText: "Google Photos Library API" }) + .getByRole("button", { name: "Add Google Photos Library API" }) + .click(); + const libraryCard = page + .getByTestId("catalog-photos.google.com-openapi") + .filter({ hasText: "Google Photos Library API" }); + await libraryCard + .getByRole("link", { name: "View Google Photos Library API" }) + .waitFor({ timeout: 120_000 }); + expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/); + // Product identity survived: the LIBRARY slug became the namespace… + const href = await libraryCard + .getByRole("link", { name: "View Google Photos Library API" }) + .getAttribute("href"); + expect(href).toContain("/integrations/google_photos_library"); + // …and the Picker card, same domain and kind, still offers Add. + await page + .getByTestId("catalog-photos.google.com-openapi") + .filter({ hasText: "Google Photos Picker API" }) + .getByRole("button", { name: "Add Google Photos Picker API" }) + .waitFor(); }); - await step("The Library link carries the focused OpenAPI preset", async () => { - const url = new URL(page.url()); - expect(url.searchParams.get("preset")).toBe("google-photos-library"); - expect(url.searchParams.get("url")).toContain("photoslibrary"); - await expect.poll(() => page.locator("textarea").inputValue()).toContain("photoslibrary"); + await step("Adding the SECOND same-kind card keeps its own identity", async () => { + // The regression this guards: selection re-derived the surface by + // KIND, so the second product of a kind inherited the first's slug, + // auth, and overrides. + await page + .getByTestId("catalog-photos.google.com-openapi") + .filter({ hasText: "Google Photos Picker API" }) + .getByRole("button", { name: "Add Google Photos Picker API" }) + .click(); + const pickerCard = page + .getByTestId("catalog-photos.google.com-openapi") + .filter({ hasText: "Google Photos Picker API" }); + await pickerCard + .getByRole("link", { name: "View Google Photos Picker API" }) + .waitFor({ timeout: 120_000 }); + const href = await pickerCard + .getByRole("link", { name: "View Google Photos Picker API" }) + .getAttribute("href"); + expect(href).toContain("/integrations/google_photos_picker"); }); }); }), diff --git a/e2e/scenarios/integrations-catalog-search.test.ts b/e2e/scenarios/integrations-catalog-search.test.ts index 4645a3e07..088ff25ee 100644 --- a/e2e/scenarios/integrations-catalog-search.test.ts +++ b/e2e/scenarios/integrations-catalog-search.test.ts @@ -1,77 +1,116 @@ +import { randomBytes } from "node:crypto"; + import { expect } from "@effect/vitest"; import { Effect } from "effect"; +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; -import { clickToReveal, visit } from "../src/surfaces/browser"; +import { visit } from "../src/surfaces/browser"; -// The connect dialog's long-tail search goes to the public integrations.sh -// registry from the browser. CI must not depend on the live service, so both -// registry endpoints are fulfilled at the network layer here — including the -// CORS header a real cross-origin browser call needs. +// The picker's registry search goes to the public integrations.sh service from +// the browser. CI must not depend on the live service, so the search endpoint +// is fulfilled at the network layer here — including the CORS header a real +// cross-origin browser call needs. The MCP endpoints themselves are NOT +// stubbed: the quick add probes and registers server-side, so the working row +// points at a local fixture server and the broken row at a reserved-TLD host. scenario( - "Connect dialog: integrations.sh catalog search resolves into a prefilled add flow", + "Add page: a registry row adds in place, and falls back to the config screen", {}, - Effect.gen(function* () { - const target = yield* Target; - const browser = yield* Browser; - const identity = yield* target.newIdentity(); + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + // Unique per run — the derived namespace must not collide on targets + // whose identities share one tenant (selfhost admin). + const suffix = randomBytes(3).toString("hex"); + const server = yield* serveMcpServer(() => + makeGreetingMcpServer({ name: `catalog-quick-${suffix}` }), + ); + const identity = yield* target.newIdentity(); - yield* browser.session(identity, async ({ page, step }) => { - await step("Stub the integrations.sh registry endpoints", async () => { - await page.route("https://integrations.sh/api/search*", (route) => - route.fulfill({ - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - json: { - results: [ - { - domain: "todoist.com", - name: "todoist.com", - description: "Tasks, projects, and collaboration.", - kinds: ["mcp", "cli"], - url: "https://integrations.sh/todoist.com/", - }, - ], - }, - }), - ); - await page.route("https://integrations.sh/api/todoist.com/surface", (route) => - route.fulfill({ - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - json: { - version: 3, - domain: "todoist.com", - surfaces: [ - { type: "mcp", url: "https://ai.todoist.net/mcp", slug: "todoist" }, - { type: "cli", slug: "todoist-cli" }, - ], - }, - }), - ); - }); + yield* browser.session(identity, async ({ page, step }) => { + await step("Stub the integrations.sh search endpoint", async () => { + await page.route("https://integrations.sh/api/search*", (route) => + route.fulfill({ + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + json: { + results: [ + { + domain: "todoist.com", + name: "todoist.com", + description: "Tasks, projects, and collaboration.", + kinds: ["mcp", "cli"], + surfaces: [ + { kind: "mcp", slug: `todoist-${suffix}`, url: server.endpoint }, + { kind: "cli", slug: "todoist-cli" }, + ], + }, + { + domain: "deadserver.com", + name: "deadserver.com", + description: "A vendor whose MCP endpoint is gone.", + kinds: ["mcp"], + surfaces: [ + { kind: "mcp", slug: "deadserver", url: "https://mcp.notareal.invalid/mcp" }, + ], + }, + ], + }, + }), + ); + }); - await step("Searching surfaces the catalog row under the presets", async () => { - await visit(page, "/integrations"); - const dialog = page.getByRole("dialog", { name: "Connect an integration" }); - await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); - await dialog.getByPlaceholder(/Search or paste a URL/).fill("todoist"); - // The CLI-only surface is not offered; the connectable kind is. - await dialog - .getByRole("button", { name: /todoist\.com/ }) - .getByText("MCP") - .waitFor(); - }); + await step("Searching surfaces the connectable rows, not the CLI", async () => { + await visit(page, "/integrations/browse"); + await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist"); + await page.getByTestId(`catalog-todoist-${suffix}`).getByText("Todoist MCP").waitFor(); + await page.getByTestId("catalog-deadserver").waitFor(); + expect(await page.getByTestId("catalog-todoist-cli").count()).toBe(0); + }); + + await step("Add registers in place; the card flips to View", async () => { + await page + .getByTestId(`catalog-todoist-${suffix}`) + .getByRole("button", { name: "Add Todoist MCP" }) + .click(); + // Generous timeout: the quick add probes the fixture server and + // registers, all behind one click, and the dev server can queue + // under CI load. + await page + .getByTestId(`catalog-todoist-${suffix}`) + .getByRole("link", { name: "View Todoist MCP" }) + .waitFor({ timeout: 90_000 }); + // The whole point: the user never left the picker. + expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/); + }); + + await step("View jumps to the integration's hub", async () => { + await page + .getByTestId(`catalog-todoist-${suffix}`) + .getByRole("link", { name: "View Todoist MCP" }) + .click(); + await page.waitForURL(new RegExp(`/integrations/todoist_${suffix}`)); + await page.goBack(); + await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist"); + await page.getByTestId("catalog-deadserver").waitFor(); + }); - await step("Picking the row lands on the MCP add flow, prefilled", async () => { - const dialog = page.getByRole("dialog", { name: "Connect an integration" }); - await dialog.getByRole("button", { name: /todoist\.com/ }).click(); - await page.waitForURL(/\/integrations\/add\/mcp/); - const url = new URL(page.url()); - expect(url.searchParams.get("url")).toBe("https://ai.todoist.net/mcp"); - expect(url.searchParams.get("namespace")).toBe("todoist"); + await step("An unreachable endpoint falls back to the config screen", async () => { + await page + .getByTestId("catalog-deadserver") + .getByRole("button", { name: "Add Deadserver MCP" }) + .click(); + // Quick add cannot register it, so the click lands on the add flow + // prefilled with the same facts — where the probe failure renders + // with retry UX. + await page.waitForURL(/\/integrations\/add\/mcp/, { timeout: 90_000 }); + const url = new URL(page.url()); + expect(url.searchParams.get("url")).toBe("https://mcp.notareal.invalid/mcp"); + expect(url.searchParams.get("namespace")).toBe("deadserver"); + }); }); - }); - }), + }), + ), ); diff --git a/e2e/scenarios/mcp-catalog-sync-ui.test.ts b/e2e/scenarios/mcp-catalog-sync-ui.test.ts index bda26e2cd..077bde0a2 100644 --- a/e2e/scenarios/mcp-catalog-sync-ui.test.ts +++ b/e2e/scenarios/mcp-catalog-sync-ui.test.ts @@ -40,7 +40,7 @@ scenario( // The URL auto-probes (debounced); the method list appears once the // probe lands — an open server seeds the detected no-auth method. await page.getByText("How does this server authenticate?").waitFor(); - await page.getByText("Method 1 · Detected").waitFor(); + await page.getByText("No authentication · Detected").waitFor(); }); await step("Add the integration", async () => { diff --git a/e2e/scenarios/provider-plugins-ui.test.ts b/e2e/scenarios/provider-plugins-ui.test.ts index 81ba31e2c..b87355816 100644 --- a/e2e/scenarios/provider-plugins-ui.test.ts +++ b/e2e/scenarios/provider-plugins-ui.test.ts @@ -3,10 +3,16 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; -import { clickToReveal, visit } from "../src/surfaces/browser"; +import { visit } from "../src/surfaces/browser"; +const gmailSpecUrl = "https://integrations.sh/specs/google/google-gmail.json"; +const outlookSpecUrl = "https://integrations.sh/specs/microsoft-graph/mail.json"; + +// Provider products are ordinary integrations.sh results now. Stub both the +// search response and the per-domain surface documents so this scenario proves +// the full registry-card-to-add-flow journey without depending on the service. scenario( - "Provider catalog · Google and Microsoft services are OpenAPI presets", + "Google and Microsoft products are registry cards that resolve into prefilled add flows", {}, Effect.gen(function* () { const target = yield* Target; @@ -14,58 +20,91 @@ scenario( const identity = yield* target.newIdentity(); yield* browser.session(identity, async ({ page, step }) => { - await step("Open the integrations picker from the sidebar", async () => { - await visit(page, "/policies"); - await clickToReveal( - page - .getByRole("navigation") - .getByRole("button", { name: "Browse integrations", exact: true }), - page.getByRole("dialog", { name: "Connect an integration" }), + await step("Stub the Google and Microsoft registry products", async () => { + await page.route("https://integrations.sh/api/search*", (route) => + route.fulfill({ + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + json: { + results: [ + { + domain: "gmail.com", + name: "Gmail", + description: "Email from Google.", + kinds: ["openapi"], + url: "https://integrations.sh/gmail.com/", + }, + { + domain: "graph.microsoft.com", + name: "Outlook Mail", + description: "Email from Microsoft.", + kinds: ["openapi"], + url: "https://integrations.sh/graph.microsoft.com/", + }, + ], + }, + }), + ); + await page.route("https://integrations.sh/api/gmail.com/surface", (route) => + route.fulfill({ + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + json: { + version: 3, + domain: "gmail.com", + surfaces: [{ type: "http", slug: "google-gmail", spec: gmailSpecUrl }], + }, + }), + ); + await page.route("https://integrations.sh/api/graph.microsoft.com/surface", (route) => + route.fulfill({ + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + json: { + version: 3, + domain: "graph.microsoft.com", + surfaces: [{ type: "http", slug: "outlook-mail", spec: outlookSpecUrl }], + }, + }), ); }); - await step("The picker exposes OpenAPI plus provider service presets", async () => { - const dialog = page.getByRole("dialog", { - name: "Connect an integration", - }); - const search = dialog.getByPlaceholder(/Search or paste a URL/); - await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor(); - + await step("Search for Gmail and see its registry card and domain", async () => { + await visit(page, "/integrations/browse"); + const search = page.getByPlaceholder(/Search integrations, or paste a URL/); await search.fill("gmail"); - await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor(); - - await search.fill("onedrive"); - await dialog.getByRole("link", { name: /^OneDrive Files\b/ }).waitFor(); + const card = page.getByTestId("catalog-gmail.com-openapi"); + await card.waitFor(); + await card.getByText("gmail.com").waitFor(); }); - await step("OpenAPI add remains generic", async () => { - await page.goto("/integrations/add/openapi", { - waitUntil: "domcontentloaded", - }); - await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); - await page.getByText("OpenAPI Spec").waitFor(); - expect(await page.getByText("Customize your Google connection").count()).toBe(0); - expect(await page.getByText("Customize Microsoft Graph").count()).toBe(0); + await step("Add Gmail in place: the card flips to View", async () => { + await page + .getByTestId("catalog-gmail.com-openapi") + .getByRole("button", { name: "Add Gmail API" }) + .click(); + // The quick add fetches the hosted spec server-side and registers — + // no navigation. Generous timeout: a real spec fetch + parse rides + // behind the click. + await page + .getByTestId("catalog-gmail.com-openapi") + .getByRole("link", { name: "View Gmail API" }) + .waitFor({ timeout: 120_000 }); + expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/); + // The registry surface slug became the namespace. + const href = await page + .getByTestId("catalog-gmail.com-openapi") + .getByRole("link", { name: "View Gmail API" }) + .getAttribute("href"); + expect(href).toContain("/integrations/google_gmail"); }); - await step("A Google service preset opens the OpenAPI add flow", async () => { - await page.goto( - "/integrations/add/openapi?preset=google-gmail&url=https%3A%2F%2Fwww.googleapis.com%2Fdiscovery%2Fv1%2Fapis%2Fgmail%2Fv1%2Frest", - { waitUntil: "domcontentloaded" }, - ); - await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); - await expect.poll(() => page.locator("textarea").inputValue()).toContain("gmail"); - }); - - await step("A Microsoft service preset opens the OpenAPI add flow", async () => { - await page.goto( - "/integrations/add/openapi?preset=microsoft-files&url=https%3A%2F%2Fgithub.com%2FUsefulSoftwareCo%2Fexecutor%2Freleases%2Fdownload%2Fgraph-slices%2Ffiles.yaml", - { waitUntil: "domcontentloaded" }, - ); - await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); - await expect - .poll(() => page.locator("textarea").inputValue()) - .toContain("graph-slices/files.yaml"); + await step("Search for Outlook and see its separate registry card and domain", async () => { + await visit(page, "/integrations/browse"); + await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("outlook"); + const card = page.getByTestId("catalog-graph.microsoft.com-openapi"); + await card.waitFor(); + await card.getByText("graph.microsoft.com").waitFor(); }); }); }), diff --git a/e2e/selfhost/auth-methods-ui.test.ts b/e2e/selfhost/auth-methods-ui.test.ts index fd06b0c7c..a88d406d6 100644 --- a/e2e/selfhost/auth-methods-ui.test.ts +++ b/e2e/selfhost/auth-methods-ui.test.ts @@ -169,7 +169,7 @@ scenario( }); await step("The probe detected OAuth", async () => { - await page.getByText("Method 1 · Detected").waitFor(); + await page.getByText("OAuth · Detected").waitFor(); // The OAuth editor declares discovery-at-connect, not pasted URLs. await page.getByText("OAuth metadata is discovered from this server").waitFor(); }); diff --git a/e2e/selfhost/detected-auth-immutable-ui.test.ts b/e2e/selfhost/detected-auth-immutable-ui.test.ts index 4fa7c8ff1..2cf8e0b07 100644 --- a/e2e/selfhost/detected-auth-immutable-ui.test.ts +++ b/e2e/selfhost/detected-auth-immutable-ui.test.ts @@ -1,6 +1,6 @@ // Selfhost-only (browser): a spec/probe-DETECTED auth method is immutable in // the add flow. The shared AuthMethodListEditor renders detected methods as a -// disabled, read-only summary ("Pulled from spec. Remove to override.") with no +// disabled, read-only summary (declared-by-this-API footer) with no // kind selector, so a user can't silently retype the spec's method into a kind // nothing backs. A method the user adds by hand stays fully editable. Both the // MCP and OpenAPI add flows compose the same editor, so one behavior, two @@ -18,7 +18,9 @@ import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; import { visit } from "../src/surfaces/browser"; -const REMOVE_HINT = "Pulled from spec. Remove to override."; +// The detected-method footer names the source and what happens next; the +// stable common prefix is the assertion target. +const REMOVE_HINT = "Declared by this API."; scenario( "Detected auth · an MCP probe's OAuth method is immutable in the add flow", @@ -39,20 +41,16 @@ scenario( await step("Open the add-MCP flow pointed at the OAuth server", async () => { await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); await page.getByText("How does this server authenticate?").waitFor(); - await page.getByText("Method 1 · Detected").waitFor(); + await page.getByText("OAuth · Detected").waitFor(); }); await step("The detected method is locked: read-only, named, no selector", async () => { - // The kind is named explicitly ("OAuth"), the discovered-OAuth summary - // and override hint sit inside a disabled block, and there is NO - // editable kind selector (the FilterTabs render as buttons). - await page.getByText("OAuth", { exact: true }).first().waitFor(); + // The kind is named in the row TITLE ("OAuth · Detected", asserted + // above), the discovered-OAuth summary and override hint render + // read-only, and there is NO editable kind selector (the FilterTabs + // render as buttons). await page.getByText("OAuth metadata is discovered from this server").waitFor(); await page.getByText(REMOVE_HINT).waitFor(); - expect( - await page.locator("[aria-disabled]").count(), - "the detected method renders a disabled (non-interactive) block", - ).toBeGreaterThan(0); expect( await page.getByRole("button", { name: "API key", exact: true }).count(), "no editable kind selector is shown for the detected method", @@ -150,7 +148,9 @@ scenario( .first() .fill(spec.url); await page.getByText("How does this API authenticate?").waitFor(); - await page.getByText("Method 2").waitFor(); + // Detected rows are titled by their kind now, not "Method N"; the + // hint below is what marks a rendered detected method. + await page.getByText(REMOVE_HINT).first().waitFor(); }); await step("Both detected methods are locked, named, read-only", async () => { @@ -162,8 +162,9 @@ scenario( "both detected methods show the remove-to-override hint", ).toBe(2); await page.getByText("https://api.acme.test/oauth/authorize").waitFor(); - await page.getByText("API key", { exact: true }).first().waitFor(); - await page.getByText("OAuth", { exact: true }).first().waitFor(); + // Kinds are named in the row TITLES ("API key · …" / "OAuth · …"). + await page.getByText("API key · ").first().waitFor(); + await page.getByText("OAuth · ").first().waitFor(); expect( await page.getByRole("button", { name: "OAuth", exact: true }).count(), "no editable kind selector is shown for the detected methods", diff --git a/e2e/selfhost/mcp-auth-required-add.test.ts b/e2e/selfhost/mcp-auth-required-add.test.ts index dfd6c7dd3..abd82a45d 100644 --- a/e2e/selfhost/mcp-auth-required-add.test.ts +++ b/e2e/selfhost/mcp-auth-required-add.test.ts @@ -61,7 +61,7 @@ scenario( }); await step("The probe seeded a detected Bearer-header method", async () => { - await page.getByText("Method 1 · Detected").waitFor(); + await page.getByText("API key · Detected").waitFor(); // The preview card flags the gate rather than failing the probe. await page.getByText("Auth required").first().waitFor(); }); diff --git a/e2e/selfhost/posthog-mcp-oauth.test.ts b/e2e/selfhost/posthog-mcp-oauth.test.ts index e6c3efe85..146c6a7a7 100644 --- a/e2e/selfhost/posthog-mcp-oauth.test.ts +++ b/e2e/selfhost/posthog-mcp-oauth.test.ts @@ -51,7 +51,7 @@ scenario( addUrl.searchParams.set("url", server.endpoint); await visit(page, addUrl.toString()); await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); - await page.getByText("Method 1 · Detected").waitFor(); + await page.getByText("OAuth · Detected").waitFor(); await page.getByText("OAuth metadata is discovered from this server").waitFor(); }); diff --git a/package.json b/package.json index 007a4bb01..c284f4562 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "packages/*/*", "packages/react", "packages/app", + "packages/onboarding-demo", "apps/*", "examples/*", "e2e" diff --git a/packages/app/src/routeTree.gen.ts b/packages/app/src/routeTree.gen.ts index ab013d687..649117c69 100644 --- a/packages/app/src/routeTree.gen.ts +++ b/packages/app/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './ import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts' import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' +import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../react/src/routes/integrations.browse' import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug' import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId' @@ -70,6 +71,12 @@ const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute = path: '/{-$orgSlug}/resume/$executionId', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute = + DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport.update({ + id: '/{-$orgSlug}/integrations/browse', + path: '/{-$orgSlug}/integrations/browse', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute = DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport.update({ id: '/{-$orgSlug}/integrations/$namespace', @@ -113,6 +120,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -128,6 +136,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -144,6 +153,7 @@ export interface FileRoutesById { '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -161,6 +171,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -176,6 +187,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -191,6 +203,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -206,6 +219,7 @@ export interface RootRouteChildren { DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute + DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute @@ -269,6 +283,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/integrations/browse': { + id: '/{-$orgSlug}/integrations/browse' + path: '/{-$orgSlug}/integrations/browse' + fullPath: '/{-$orgSlug}/integrations/browse' + preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/integrations/$namespace': { id: '/{-$orgSlug}/integrations/$namespace' path: '/{-$orgSlug}/integrations/$namespace' @@ -353,6 +374,8 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute, DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute, + DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute: + DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute, DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute, DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx index 0dbfb7e59..5a339889e 100644 --- a/packages/app/src/web/shell.tsx +++ b/packages/app/src/web/shell.tsx @@ -136,6 +136,12 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", ].join(" ")} > + {/* Same inputs as the integrations list. Passing only the + slug and kind meant an icon could be found ONLY when a + bundled preset happened to match — anything added from the + registry has a slug no preset knows, so the sidebar sat + blank while the same integration showed its mark in the + list. The display URL is what a favicon is derived from. */} Promise) | null) => void; } +/** What a registry row already knows about a connect target — enough, for + * some plugins, to register without showing the configuration screen. */ +export interface IntegrationQuickAddInput { + /** The connect target: MCP endpoint, OpenAPI spec URL, or GraphQL endpoint. */ + readonly url: string; + /** Display name for the integration ("Stripe MCP", "Outlook Mail API"). */ + readonly name: string; + /** Registry surface slug, used as the namespace seed when present. */ + readonly slug?: string; + /** The registry PRODUCT's domain (notion.com) — the identity credential + * guidance and favicons key on. Often differs from the connect URL's + * host: specs live on code hosts. */ + readonly domain?: string; + /** Registry-declared credential placement, e.g. "Authorization: {api_key}". */ + readonly authHeader?: string; + /** Registry-declared credential kind ("none", "oauth", "api_key", …). */ + readonly authKind?: string; + /** RFC 6902 patch the registry says to apply to the fetched spec. */ + readonly specOverrides?: readonly unknown[]; +} + +export type IntegrationQuickAddResult = + /** Registered; `slug` is the created integration's namespace. */ + | { readonly ok: true; readonly slug: string } + /** Could not add headlessly — the host falls back to the configuration + * screen, which renders the failure with full context. */ + | { readonly ok: false; readonly reason: string }; + export interface IntegrationPlugin { /** Unique key matching the SDK plugin id (e.g. "openapi"). */ readonly key: string; @@ -213,6 +247,21 @@ export interface IntegrationPlugin { readonly initialUrl?: string; readonly initialPreset?: string; readonly initialNamespace?: string; + /** Registry-declared credential placement for the surface, e.g. + * "Authorization: {api_key}" — the pattern Linear's no-Bearer personal + * keys need. Plugins whose surfaces can't self-describe auth (GraphQL) + * seed their auth-method editor from it. */ + readonly initialAuthHeader?: string; + readonly initialAuthNote?: string; + /** Registry-declared credential kind ("none", "oauth", "api_key", …). + * Lets a flow whose live probe fails, or whose surface can't be probed, + * still declare the right method — an authless MCP server is a fact the + * registry already knows. */ + readonly initialAuthKind?: string; + /** JSON-encoded RFC 6902 patch the registry says to apply to the fetched + * spec — the registry's mechanism for improving a vendor's published + * document over time without hosting a fork. */ + readonly initialSpecOverrides?: string; }>; /** Legacy full-page edit surface. No host renders this anymore — plugin * configuration lives in the integration Edit sheet via `editSheet`. */ @@ -245,6 +294,17 @@ export interface IntegrationPlugin { * Call from the host on intent (hover/focus) so the chunks land before the * user navigates into the add page. Idempotent. */ readonly preload?: () => void; + /** Headless one-click add for a registry row whose facts are already known + * (URL + auth indicators). A HOOK, not a plain function, because each + * plugin binds its own mutation atoms — the host mounts one bridge + * component per plugin to collect the bound callbacks. Absent means the + * plugin always needs its configuration screen. A `{ok: false}` result is + * an expected outcome (unreachable server, slug collision), not an error: + * the host falls back to the configuration screen prefilled with the same + * facts. */ + readonly useQuickAdd?: () => ( + input: IntegrationQuickAddInput, + ) => Promise; } // --------------------------------------------------------------------------- diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index d7497bc42..5751a2857 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -612,6 +612,10 @@ export interface IntegrationPreset { readonly specOverrides?: readonly unknown[]; readonly authTemplate?: readonly IntegrationPresetAuthentication[]; readonly healthCheck?: HealthCheckSpec; + /** The public registry lists this product: the picker shows the registry's + * card, and the preset's knowledge rides quick add instead. A custom + * deployment preset leaves this unset and keeps its own card. */ + readonly registryListed?: boolean; readonly transport?: "remote" | "stdio"; readonly command?: string; readonly args?: readonly string[]; diff --git a/packages/onboarding-demo/CHANGELOG.md b/packages/onboarding-demo/CHANGELOG.md new file mode 100644 index 000000000..acec5c16d --- /dev/null +++ b/packages/onboarding-demo/CHANGELOG.md @@ -0,0 +1 @@ +# @executor-js/onboarding-demo diff --git a/packages/onboarding-demo/README.md b/packages/onboarding-demo/README.md new file mode 100644 index 000000000..babbed444 --- /dev/null +++ b/packages/onboarding-demo/README.md @@ -0,0 +1,53 @@ +# Onboarding demo + +A standalone reproduction of executor's **current** add-integration flow, from +"the CLI is connected" to "the integration has a working connection". No API, no +auth, no tenant — it runs off `src/fixtures.ts` and the public integrations.sh +endpoints. + +``` +bun run --cwd packages/onboarding-demo dev # http://localhost:5199 +``` + +Every screen is addressable: `#integrations-empty`, `#connect-dialog`, +`#add-openapi`, `#detail-accounts`, `#add-account-credential`, +`#add-account-place`, `#oauth-stuck`, `#integrations-populated`. `[` and `]` +step through them. `src/flow.ts` records, per screen, the route and source +component it was reproduced from plus the first-run reactions against it. + +A floating Current / Reworked control in the bottom-right corner switches +between the two flows. + +## Reworked flow + +`#reworked` — the picker, `#reworked/installed`, `#reworked/custom`, +`#reworked/`, `#reworked//auth`. Browse/Installed carries +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 +says so rather than rendering an empty screen — the prototype keeps no state +across reloads. + +Registry picks and custom URLs converge: both become an added integration with +one `default` account reading Needs auth. Only the detail header differs, naming +where the definition came from. + +## Fidelity + +- The curated preset list is imported from the real plugin modules + (`@executor-js/plugin-openapi/presets`, `@executor-js/plugin-mcp/presets`), + so it is the list the console shows, in the console's order. +- UI primitives and design tokens are imported from `@executor-js/react`, not + copied — the reproduction uses the same buttons, dialogs, card stacks, tabs + and tokens as the console. +- The catalog search calls the real `integrations.sh/api/search` with the same + 250ms debounce and 2-character minimum. +- Screen structure and copy are transcribed from the source components named in + the inspector. + +What is faked: the workspace's own data (integrations, connections, tools) and +every mutation. Adding an integration waits and moves on; nothing persists. + +`fixtures.ts` also exposes two integrations.sh endpoints the console does **not** +use today — `fetchCatalogDomains` (the whole ~3.4k-domain popularity-sorted +registry) and `fetchSurfaceCredentials` (per-domain credential label, the URL +that mints the key, and setup instructions). They are unused by the +reproduction and present as raw material for the rework. diff --git a/packages/onboarding-demo/index.html b/packages/onboarding-demo/index.html new file mode 100644 index 000000000..a9c61987a --- /dev/null +++ b/packages/onboarding-demo/index.html @@ -0,0 +1,18 @@ + + + + + + Executor onboarding — current flow + + + + + +
+ + + diff --git a/packages/onboarding-demo/package.json b/packages/onboarding-demo/package.json new file mode 100644 index 000000000..39179d3a5 --- /dev/null +++ b/packages/onboarding-demo/package.json @@ -0,0 +1,32 @@ +{ + "name": "@executor-js/onboarding-demo", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/react": "workspace:*", + "@executor-js/sdk": "workspace:*", + "lucide-react": "^1.7.0", + "react": "catalog:", + "react-dom": "catalog:", + "sonner": "^2.0.7", + "tldts": "^7.0.28" + }, + "devDependencies": { + "@tailwindcss/vite": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/onboarding-demo/src/app.tsx b/packages/onboarding-demo/src/app.tsx new file mode 100644 index 000000000..ba048ae6f --- /dev/null +++ b/packages/onboarding-demo/src/app.tsx @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { cn } from "@executor-js/react/lib/utils"; +import { Shell } from "./shell"; +import { IntegrationsPage } from "./screens/integrations-page"; +import { ConnectDialog } from "./screens/connect-dialog"; +import { AddOpenApiPage } from "./screens/add-openapi-page"; +import { IntegrationDetailPage } from "./screens/integration-detail"; +import { AddAccountModal } from "./screens/add-account-modal"; +import { flowSteps, type StepId } from "./flow"; +import { stepState, type AccountModal, type ScreenView } from "./step-state"; +import { NextFlow } from "./next-flow"; +import { + gmailAuthMethods, + gmailIntegration, + posthogAuthMethods, + posthogIntegration, + seededIntegrations, + type DemoIntegration, + type DemoPreset, +} from "./fixtures"; + +const posthogPreset: DemoPreset = { + id: "posthog", + name: "PostHog", + summary: "Product analytics, events, feature flags, and insights.", + icon: "https://integrations.sh/logo/posthog.com", + url: "https://raw.githubusercontent.com/PostHog/posthog/master/openapi/openapi.json", + pluginKey: "openapi", + pluginLabel: "OpenAPI", +}; + +const stepFromHash = (): StepId => { + const hash = globalThis.location?.hash.replace(/^#/, "") ?? ""; + return flowSteps.some((step) => step.id === hash) ? (hash as StepId) : "integrations-empty"; +}; + +function CurrentFlowApp() { + // Each screen is addressable as `#`, so a specific screen can be + // linked to directly rather than clicked toward. `[` and `]` step through. + const [stepId, setStepId] = useState(stepFromHash); + const [overrides, setOverrides] = useState<{ + readonly integrations?: readonly DemoIntegration[]; + readonly connectOpen?: boolean; + readonly accountModal?: AccountModal; + readonly view?: ScreenView; + }>({}); + + const base = useMemo(() => stepState[stepId], [stepId]); + const state = { ...base, ...overrides }; + + const goToStep = useCallback((id: StepId) => { + setStepId(id); + setOverrides({}); + if (globalThis.location) globalThis.location.hash = id; + }, []); + + useEffect(() => { + const onHashChange = () => { + setStepId(stepFromHash()); + setOverrides({}); + }; + globalThis.addEventListener("hashchange", onHashChange); + return () => globalThis.removeEventListener("hashchange", onHashChange); + }, []); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== "[" && event.key !== "]") return; + const index = flowSteps.findIndex((candidate) => candidate.id === stepId); + const next = event.key === "]" ? index + 1 : index - 1; + const target = flowSteps[next]; + if (target) goToStep(target.id); + }; + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, [stepId, goToStep]); + + const integrations = state.integrations ?? seededIntegrations; + + return ( + <> + + {state.view === "integrations" && ( + setOverrides((prev) => ({ ...prev, connectOpen: true }))} + onOpenIntegration={() => setOverrides((prev) => ({ ...prev, view: "detail" }))} + /> + )} + + {state.view === "add" && ( + goToStep("integrations-empty")} + onComplete={() => goToStep("detail-accounts")} + /> + )} + + {state.view === "detail" && ( + + setOverrides((prev) => ({ ...prev, accountModal: "credential" })) + } + /> + )} + + + setOverrides((prev) => ({ ...prev, connectOpen: open }))} + onPickPreset={() => goToStep("add-openapi")} + onPickPlugin={() => goToStep("add-openapi")} + onPickCatalogEntry={() => goToStep("add-openapi")} + /> + + {state.accountModal !== "none" && ( + + setOverrides((prev) => ({ ...prev, accountModal: open ? prev.accountModal : "none" })) + } + integrationName={state.accountModal === "oauth-stuck" ? "Gmail" : "PostHog"} + methods={state.accountModal === "oauth-stuck" ? gmailAuthMethods : posthogAuthMethods} + initialStep={state.accountModal === "place" ? "place" : "validate"} + stuckConnecting={state.accountModal === "oauth-stuck"} + onAdded={() => goToStep("integrations-populated")} + /> + )} + + ); +} + +// --------------------------------------------------------------------------- +// Mode switch +// +// Both flows run in the same console chrome, so the comparison is about the +// flow rather than the styling. The switch is a small floating control rather +// than a panel: it is scaffolding for looking at the prototype, and it should +// not take width away from the thing being looked at. +// --------------------------------------------------------------------------- + +type Mode = "current" | "next"; + +function ModeSwitch(props: { readonly mode: Mode; readonly onChange: (mode: Mode) => void }) { + const option = (mode: Mode, label: string) => ( + // oxlint-disable-next-line react/forbid-elements + + ); + return ( +
+ {option("current", "Current")} + {option("next", "Reworked")} +
+ ); +} + +const modeFromHash = (): Mode => { + const hash = globalThis.location?.hash ?? ""; + return hash.startsWith("#reworked") || hash.startsWith("#next") ? "next" : "current"; +}; + +export function App() { + const [mode, setMode] = useState(modeFromHash); + + useEffect(() => { + const onHashChange = () => setMode(modeFromHash()); + globalThis.addEventListener("hashchange", onHashChange); + return () => globalThis.removeEventListener("hashchange", onHashChange); + }, []); + + const changeMode = (next: Mode) => { + setMode(next); + if (globalThis.location) { + globalThis.location.hash = next === "next" ? "reworked" : "integrations-empty"; + } + }; + + return ( +
+ {mode === "next" ? : } + +
+ ); +} diff --git a/packages/onboarding-demo/src/catalog.ts b/packages/onboarding-demo/src/catalog.ts new file mode 100644 index 000000000..166aecd3b --- /dev/null +++ b/packages/onboarding-demo/src/catalog.ts @@ -0,0 +1,163 @@ +// --------------------------------------------------------------------------- +// The catalog behind the reworked picker. +// +// Everything here except the category is real registry data, loaded live from +// integrations.sh: ~3.4k domains, popularity-sorted, with icons, descriptions +// and the formats each one exposes. +// +// CATEGORY IS A STAND-IN. integrations.sh has a `categories` field, but only +// the 13 hand-curated entries populate it — the other ~3,365 domains have none. +// A category rail like the reference picker's therefore needs the catalog +// categorised first (the "leverage AI for the generation" step). The keyword +// pass below exists so the rail is real enough to design against; it is not a +// taxonomy and should not survive into the product. +// --------------------------------------------------------------------------- + +import { fetchCatalogDomains, type CatalogDomain } from "./fixtures"; + +export type Category = + | "Featured" + | "Inbox & calendar" + | "Docs & files" + | "Developer tools" + | "Analytics" + | "Payments" + | "Support" + | "Project management" + | "Infrastructure" + | "Everything else"; + +export const CATEGORY_ORDER: readonly Category[] = [ + "Featured", + "Inbox & calendar", + "Docs & files", + "Developer tools", + "Analytics", + "Payments", + "Support", + "Project management", + "Infrastructure", + "Everything else", +]; + +const KEYWORDS: readonly (readonly [Category, readonly string[]])[] = [ + ["Inbox & calendar", ["email", "mail", "calendar", "inbox", "meeting", "scheduling"]], + ["Docs & files", ["document", "file", "drive", "storage", "note", "wiki", "knowledge"]], + ["Analytics", ["analytic", "metric", "tracking", "telemetry", "dashboard", "insight"]], + ["Payments", ["payment", "invoice", "billing", "subscription", "checkout", "payout"]], + ["Support", ["support", "ticket", "helpdesk", "customer", "chat"]], + ["Project management", ["issue", "task", "project", "sprint", "roadmap", "backlog"]], + [ + "Infrastructure", + ["deploy", "server", "cluster", "database", "kubernetes", "cloud", "hosting", "dns"], + ], + ["Developer tools", ["repo", "commit", "pull request", "ci", "build", "code", "sdk", "api"]], +]; + +/** Best-effort bucket from the description. Deliberately crude — see the file + * header. Anything unmatched lands in "Everything else" rather than being + * guessed at. */ +const deriveCategory = (entry: CatalogDomain): Category => { + const haystack = `${entry.domain} ${entry.description}`.toLowerCase(); + for (const [category, words] of KEYWORDS) { + if (words.some((word) => haystack.includes(word))) return category; + } + return "Everything else"; +}; + +/** Domains the reference picker would call "Featured": the ones executor + * already ships a curated preset for, which are also the ones most people + * arrive wanting. */ +const FEATURED_DOMAINS: readonly string[] = [ + "google.com", + "github.com", + "linear.app", + "notion.com", + "slack.com", + "stripe.com", + "figma.com", + "posthog.com", + "sentry.io", + "vercel.com", + "resend.com", + "asana.com", +]; + +export interface CatalogItem { + readonly domain: string; + readonly name: string; + readonly description: string; + readonly icon: string; + readonly formats: readonly string[]; + readonly category: Category; + readonly featured: boolean; + readonly popularity: number; +} + +/** Title-case a domain into something that reads like a product name. + * `linear.app` → `Linear`, `google.com` → `Google`. */ +const displayName = (domain: string): string => { + const stem = domain.split(".")[0] ?? domain; + return stem.charAt(0).toUpperCase() + stem.slice(1); +}; + +export const loadCatalog = async (): Promise => { + const domains = await fetchCatalogDomains(); + return domains.map((entry): CatalogItem => { + const featured = FEATURED_DOMAINS.includes(entry.domain); + return { + domain: entry.domain, + name: displayName(entry.domain), + description: entry.description, + icon: entry.icon, + formats: Object.keys(entry.formats ?? {}), + category: featured ? "Featured" : deriveCategory(entry), + featured, + popularity: entry.popularity, + }; + }); +}; + +export const searchItems = ( + items: readonly CatalogItem[], + query: string, +): readonly CatalogItem[] => { + const q = query.trim().toLowerCase(); + if (q.length === 0) return items; + return items.filter((item) => + `${item.domain} ${item.name} ${item.description}`.toLowerCase().includes(q), + ); +}; + +// --------------------------------------------------------------------------- +// Added integrations +// +// A registry pick and a hand-pasted URL become the SAME kind of thing the +// moment they're added. Only `source` differs, and it only affects what the +// detail screen can say about where the definition came from — never how you +// authenticate it or where it lives. +// --------------------------------------------------------------------------- + +export interface AddedIntegration { + readonly domain: string; + readonly name: string; + readonly description: string; + readonly icon: string; + readonly formats: readonly string[]; + readonly source: "registry" | "custom"; + /** Custom only: the endpoint or spec URL the user pasted. */ + readonly url?: string; +} + +export const fromCatalogItem = (item: CatalogItem): AddedIntegration => ({ + domain: item.domain, + name: item.name, + description: item.description, + icon: item.icon, + formats: item.formats, + source: "registry", +}); + +/** integrations.sh serves a favicon for any domain, registry entry or not, so + * a custom integration still gets a real logo. */ +export const logoFor = (domain: string): string => `https://integrations.sh/logo/${domain}`; diff --git a/packages/onboarding-demo/src/fixtures.ts b/packages/onboarding-demo/src/fixtures.ts new file mode 100644 index 000000000..2f3875696 --- /dev/null +++ b/packages/onboarding-demo/src/fixtures.ts @@ -0,0 +1,240 @@ +// --------------------------------------------------------------------------- +// Everything the reproduction renders from. +// +// Presets come from the REAL plugin modules, so the curated list here is the +// same list the console shows. Connections, tools and auth methods are static +// stand-ins for the API reads the console makes. The integrations.sh calls are +// the genuine public endpoints — that catalog is already client-callable, which +// is exactly why it is the obvious raw material for the rework. +// --------------------------------------------------------------------------- + +import { openApiPresets, type OpenApiPreset } from "@executor-js/plugin-openapi/presets"; +import { mcpPresets, type McpPreset } from "@executor-js/plugin-mcp/presets"; + +export type PluginKey = "openapi" | "mcp" | "graphql" | "google"; + +export interface DemoPreset { + readonly id: string; + readonly name: string; + readonly summary: string; + readonly icon?: string; + readonly url?: string; + readonly pluginKey: PluginKey; + readonly pluginLabel: string; +} + +const fromOpenApi = (preset: OpenApiPreset): DemoPreset => ({ + id: preset.id, + name: preset.name, + summary: preset.summary, + ...(preset.icon ? { icon: preset.icon } : {}), + ...(preset.url ? { url: preset.url } : {}), + pluginKey: "openapi", + pluginLabel: "OpenAPI", +}); + +const fromMcp = (preset: McpPreset): DemoPreset => ({ + id: preset.id, + name: preset.name, + summary: preset.summary, + ...(preset.icon ? { icon: preset.icon } : {}), + ...("url" in preset && preset.url ? { url: preset.url } : {}), + pluginKey: "mcp", + pluginLabel: "MCP", +}); + +/** The console's "Popular integrations" list, in the console's own order: + * every loaded plugin's presets, concatenated plugin by plugin. */ +export const curatedPresets: readonly DemoPreset[] = [ + ...openApiPresets.map(fromOpenApi), + ...mcpPresets.map(fromMcp), +]; + +/** The plugin chips under "Or add manually". */ +export const integrationPlugins: readonly { readonly key: PluginKey; readonly label: string }[] = [ + { key: "openapi", label: "OpenAPI" }, + { key: "mcp", label: "MCP" }, + { key: "graphql", label: "GraphQL" }, + { key: "google", label: "Google" }, +]; + +// --------------------------------------------------------------------------- +// Workspace state — what a brand-new tenant has, which is nothing +// --------------------------------------------------------------------------- + +export interface DemoIntegration { + readonly slug: string; + readonly name: string; + readonly kind: PluginKey; + readonly icon?: string; + readonly toolCount: number; +} + +export const seededIntegrations: readonly DemoIntegration[] = []; + +/** What the workspace looks like after the user adds PostHog — the state Theo + * reached before hunting for the key field. */ +export const posthogIntegration: DemoIntegration = { + slug: "posthog", + name: "PostHog", + kind: "openapi", + icon: "https://integrations.sh/logo/posthog.com", + toolCount: 0, +}; + +/** The OAuth-only integration, for the step where the popup dies. */ +export const gmailIntegration: DemoIntegration = { + slug: "gmail", + name: "Gmail", + kind: "google", + icon: "https://integrations.sh/logo/google.com", + toolCount: 0, +}; + +// --------------------------------------------------------------------------- +// Auth methods for the add-connection modal +// +// Shape mirrors `AuthMethod` in packages/react/src/lib/auth-placements.tsx. +// The labels are the ones a spec-derived integration actually produces: an +// OpenAPI spec whose security schemes carry no human name yields ordinal +// method labels, which is the "Method 1" tab. +// --------------------------------------------------------------------------- + +export interface DemoPlacement { + readonly carrier: "header" | "query" | "env"; + readonly name: string; + readonly prefix: string; +} + +export interface DemoAuthMethod { + readonly id: string; + readonly label: string; + readonly kind: "apikey" | "oauth" | "none"; + readonly placements: readonly DemoPlacement[]; +} + +export const posthogAuthMethods: readonly DemoAuthMethod[] = [ + { + id: "method-1", + label: "Method 1", + kind: "apikey", + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], + }, + { + id: "method-2", + label: "Method 2", + kind: "apikey", + placements: [{ carrier: "query", name: "personal_api_key", prefix: "" }], + }, + { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] }, +]; + +export const gmailAuthMethods: readonly DemoAuthMethod[] = [ + { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] }, +]; + +// --------------------------------------------------------------------------- +// integrations.sh — the live public catalog +// --------------------------------------------------------------------------- + +export const INTEGRATIONS_SH_ORIGIN = "https://integrations.sh"; + +export const CONNECTABLE_KINDS = ["mcp", "openapi", "graphql"] as const; +export type CatalogKind = (typeof CONNECTABLE_KINDS)[number]; + +export interface CatalogEntry { + readonly domain: string; + readonly description: string; + readonly kinds: readonly CatalogKind[]; +} + +export const catalogLogoUrl = (domain: string, size: number): string => + `${INTEGRATIONS_SH_ORIGIN}/logo/${domain}?sz=${size * 2}`; + +const isConnectableKind = (kind: string): kind is CatalogKind => + (CONNECTABLE_KINDS as readonly string[]).includes(kind); + +/** `/api/search` — the same endpoint the console's connect dialog calls, and + * the same 250ms-debounced, ≥2-character contract. */ +export const searchCatalog = async ( + query: string, + limit = 10, +): Promise => { + const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN); + url.searchParams.set("q", query); + url.searchParams.set("limit", String(limit)); + const response = await fetch(url); + if (!response.ok) return []; + const payload = (await response.json()) as { + readonly results?: readonly { + readonly domain: string; + readonly description: string; + readonly kinds: readonly string[]; + }[]; + }; + return (payload.results ?? []) + .map((entry) => ({ + domain: entry.domain, + description: entry.description, + kinds: entry.kinds.filter(isConnectableKind), + })) + .filter((entry) => entry.kinds.length > 0); +}; + +export interface CatalogDomain { + readonly domain: string; + readonly icon: string; + readonly description: string; + readonly formats: Readonly>; + readonly popularity: number; +} + +/** `/api/domains.json` — the whole popularity-sorted registry (~3.4k domains). + * The console never calls this; it is here because it is the list the product + * is missing, and the rework will want it. */ +export const fetchCatalogDomains = async (): Promise => { + const response = await fetch(`${INTEGRATIONS_SH_ORIGIN}/api/domains.json`); + if (!response.ok) return []; + const payload = (await response.json()) as { readonly data?: readonly CatalogDomain[] }; + return payload.data ?? []; +}; + +export interface SurfaceCredential { + readonly id: string; + readonly type: string; + readonly label: string; + readonly generateUrl?: string; + readonly setup?: string; +} + +/** `/api//surface` — the per-domain document. The console reads only + * `surfaces[].url` out of this; the `credentials` block (which key, where to + * mint it, how) is fetched and thrown away. */ +export const fetchSurfaceCredentials = async ( + domain: string, +): Promise => { + const response = await fetch( + `${INTEGRATIONS_SH_ORIGIN}/api/${encodeURIComponent(domain)}/surface`, + ); + if (!response.ok) return []; + const payload = (await response.json()) as { + readonly credentials?: Readonly< + Record< + string, + { + readonly type?: string; + readonly label?: string; + readonly generateUrl?: string; + readonly setup?: string; + } + > + >; + }; + return Object.entries(payload.credentials ?? {}).map(([id, value]) => ({ + id, + type: value.type ?? "unknown", + label: value.label ?? id, + ...(value.generateUrl ? { generateUrl: value.generateUrl } : {}), + ...(value.setup ? { setup: value.setup } : {}), + })); +}; diff --git a/packages/onboarding-demo/src/flow.ts b/packages/onboarding-demo/src/flow.ts new file mode 100644 index 000000000..2f3b13898 --- /dev/null +++ b/packages/onboarding-demo/src/flow.ts @@ -0,0 +1,111 @@ +// --------------------------------------------------------------------------- +// The flow, as one ordered list of screens. +// +// Each step carries the first-run reaction recorded against that exact screen, +// so a redesign can be checked against the complaint it is meant to answer +// rather than against a general sense that the old screen was bad. +// --------------------------------------------------------------------------- + +export type StepId = + | "integrations-empty" + | "connect-dialog" + | "add-openapi" + | "detail-accounts" + | "add-account-credential" + | "add-account-place" + | "oauth-stuck" + | "integrations-populated"; + +export interface FlowStep { + readonly id: StepId; + readonly label: string; + /** Where this screen lives in the real console. */ + readonly route: string; + /** The component this reproduction is taken from. */ + readonly source: string; + /** Verbatim first-run reactions to this screen. */ + readonly reactions: readonly string[]; +} + +export const flowSteps: readonly FlowStep[] = [ + { + id: "integrations-empty", + label: "Integrations (empty)", + route: "/", + source: "packages/react/src/pages/integrations.tsx", + reactions: [ + "Adding to clis was easy. Not clear what to do next.", + "Next is this, not a great screen to find integrations from. No text search? I just wanted to add email", + ], + }, + { + id: "connect-dialog", + label: "Connect dialog", + route: "/ (dialog)", + source: "packages/react/src/pages/integrations.tsx › ConnectDialog", + reactions: [ + "I'd love a proper like picker grid and search", + "I want to add multiple to set up for experimentation. I clicked the first one I want (posthog) and it brought me here", + "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", + ], + }, + { + id: "add-openapi", + label: "Add integration form", + route: "/integrations/add/openapi", + source: "packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx", + reactions: [ + "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", + "Hit “add” and now another loading screen, well spinning wheel button", + ], + }, + { + id: "detail-accounts", + label: "Integration detail · Accounts", + route: "/integrations/posthog?tab=accounts", + source: "packages/react/src/pages/integration-detail.tsx", + reactions: [ + "wtf is this?? I just added it why do I have to add it again??", + "The hierarchy of the integrations and connections is not something I should have to understand or care about as I initially setup", + "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", + ], + }, + { + id: "add-account-credential", + label: "Add connection · credential", + route: "/integrations/posthog?tab=accounts&addAccount=1", + source: "packages/react/src/components/add-account-modal.tsx", + reactions: [ + "Where the fk do I paste the key?? The Method 1 thing is locked", + "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", + "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.", + "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.", + ], + }, + { + id: "add-account-place", + label: "Add connection · placement + owner", + route: "/integrations/posthog?tab=accounts&addAccount=1", + source: "packages/react/src/components/add-account-modal.tsx", + reactions: ["How many times does the word “personal” appear on this page? lol"], + }, + { + id: "oauth-stuck", + label: "OAuth · stuck connecting", + route: "/integrations/gmail?tab=accounts&addAccount=1", + source: "packages/react/src/plugins/oauth-sign-in.tsx", + reactions: [ + "Closed the broken auth page and now app is stuck in “connecting...”", + "On business Gmail, allowed me to click an “Advanced options” (“I trust the developer”) thing, but normal personal accounts, no", + ], + }, + { + id: "integrations-populated", + label: "Integrations (populated)", + route: "/", + source: "packages/react/src/pages/integrations.tsx", + reactions: ["This is filling me with rage for some reason"], + }, +]; + +export const stepIndex = (id: StepId): number => flowSteps.findIndex((step) => step.id === id); diff --git a/packages/onboarding-demo/src/main.tsx b/packages/onboarding-demo/src/main.tsx new file mode 100644 index 000000000..20a192afc --- /dev/null +++ b/packages/onboarding-demo/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./app"; +import "./styles.css"; + +const root = document.getElementById("root"); +if (root) + createRoot(root).render( + + + , + ); diff --git a/packages/onboarding-demo/src/next-flow.tsx b/packages/onboarding-demo/src/next-flow.tsx new file mode 100644 index 000000000..0cf59e0d2 --- /dev/null +++ b/packages/onboarding-demo/src/next-flow.tsx @@ -0,0 +1,304 @@ +// The reworked flow, wired end to end: browse → add (in place) → the added +// integration → authenticate, plus the custom-URL door that bypasses the +// registry entirely. Adding never blocks and never navigates; the only thing +// that moves you off the picker is choosing to look at something. +// +// Every screen is a hash route, so any of them can be linked to directly: +// #reworked the picker +// #reworked/installed what you've added +// #reworked/custom the custom-URL dialog +// #reworked/ one integration +// #reworked//auth …with its authenticate dialog open + +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@executor-js/react/components/button"; +import { cn } from "@executor-js/react/lib/utils"; +import { Shell } from "./shell"; +import { BrowsePage } from "./screens/next/browse"; +import { + InstalledList, + NextIntegrationDetail, + type DemoAccount, +} from "./screens/next/integration-detail"; +import { AuthenticateDialog } from "./screens/next/authenticate"; +import { AddCustomDialog } from "./screens/next/custom"; +import { fromCatalogItem, type AddedIntegration, type CatalogItem } from "./catalog"; +import type { DemoIntegration } from "./fixtures"; + +interface Added { + readonly item: AddedIntegration; + readonly accounts: readonly DemoAccount[]; +} + +// --------------------------------------------------------------------------- +// Routing +// --------------------------------------------------------------------------- + +export type NextRoute = + | { readonly kind: "browse" } + | { readonly kind: "installed" } + | { readonly kind: "custom" } + | { readonly kind: "detail"; readonly domain: string } + | { readonly kind: "auth"; readonly domain: string }; + +const parseRoute = (hash: string): NextRoute => { + // `#reworked/...` is canonical; `#next/...` is kept working because it was + // the first spelling and may already be pasted somewhere. + const path = hash.replace(/^#/, "").replace(/^(reworked|next)\/?/, ""); + const segments = path.split("/").filter((segment) => segment.length > 0); + if (segments.length === 0) return { kind: "browse" }; + const [first, second] = segments; + if (first === "installed") return { kind: "installed" }; + if (first === "custom") return { kind: "custom" }; + if (first && second === "auth") return { kind: "auth", domain: first }; + if (first) return { kind: "detail", domain: first }; + return { kind: "browse" }; +}; + +const routeToHash = (route: NextRoute): string => { + if (route.kind === "browse") return "#reworked"; + if (route.kind === "installed") return "#reworked/installed"; + if (route.kind === "custom") return "#reworked/custom"; + if (route.kind === "auth") return `#reworked/${route.domain}/auth`; + return `#reworked/${route.domain}`; +}; + +// --------------------------------------------------------------------------- + +const toolsFor = (accounts: readonly DemoAccount[]): number => + accounts.some((account) => account.status === "connected") ? 24 : 0; + +/** What the console sidebar lists: the same added integrations, in the shape + * the shell already renders. */ +const asSidebarIntegration = (added: Added): DemoIntegration => ({ + slug: added.item.domain, + name: added.item.name, + kind: "openapi", + icon: added.item.icon, + toolCount: toolsFor(added.accounts), +}); + +export function NextFlow() { + const [added, setAdded] = useState([]); + const [route, setRoute] = useState(() => parseRoute(globalThis.location?.hash ?? "")); + + useEffect(() => { + const onHashChange = () => setRoute(parseRoute(globalThis.location?.hash ?? "")); + globalThis.addEventListener("hashchange", onHashChange); + return () => globalThis.removeEventListener("hashchange", onHashChange); + }, []); + + const go = useCallback((next: NextRoute) => { + setRoute(next); + if (globalThis.location) globalThis.location.hash = routeToHash(next); + }, []); + + const addIntegration = useCallback((item: AddedIntegration) => { + setAdded((previous) => + previous.some((entry) => entry.item.domain === item.domain) + ? previous + : [ + ...previous, + // An add always leaves exactly one account behind, already named + // and in the one state that matters. + { item, accounts: [{ label: "default", status: "needs-auth" as const }] }, + ], + ); + }, []); + + const addFromCatalog = useCallback( + (item: CatalogItem) => addIntegration(fromCatalogItem(item)), + [addIntegration], + ); + + const markConnected = useCallback((domain: string, accountLabel: string, identity: string) => { + setAdded((previous) => + previous.map((entry) => + entry.item.domain === domain + ? { + ...entry, + accounts: entry.accounts.map((account) => + account.label === accountLabel + ? { ...account, status: "connected" as const, identity } + : account, + ), + } + : entry, + ), + ); + }, []); + + const activeDomain = route.kind === "detail" || route.kind === "auth" ? route.domain : null; + const open = added.find((entry) => entry.item.domain === activeDomain) ?? null; + const pendingCount = added.filter((entry) => + entry.accounts.some((account) => account.status === "needs-auth"), + ).length; + + // A deep link can name a domain that has not been added in this session. + // Say so rather than rendering an empty detail screen. + const missing = activeDomain !== null && open === null; + + const showingInstalled = route.kind === "installed"; + const showingBrowse = route.kind === "browse" || route.kind === "custom"; + + return ( + +
+
+ {open && !missing ? ( + go({ kind: "installed" })} + onAuthenticate={() => go({ kind: "auth", domain: open.item.domain })} + onAddAccount={() => + setAdded((previous) => + previous.map((entry) => + entry.item.domain === open.item.domain + ? { + ...entry, + accounts: [ + ...entry.accounts, + { + label: `account ${entry.accounts.length + 1}`, + status: "needs-auth" as const, + }, + ], + } + : entry, + ), + ) + } + onRemove={() => { + setAdded((previous) => + previous.filter((entry) => entry.item.domain !== open.item.domain), + ); + go({ kind: "installed" }); + }} + /> + ) : missing ? ( +
+

+ {activeDomain} isn't added in this session +

+

+ This prototype keeps nothing between reloads. +

+ +
+ ) : ( + <> +
+
+ {/* oxlint-disable-next-line react/forbid-elements */} + + {/* oxlint-disable-next-line react/forbid-elements */} + +
+ {pendingCount > 0 ? ( + + {pendingCount} need{pendingCount === 1 ? "s" : ""} auth + + ) : null} +
+ + {showingInstalled ? ( + go({ kind: "detail", domain })} + onBrowse={() => go({ kind: "browse" })} + /> + ) : ( + entry.item.domain)} + onAdd={addFromCatalog} + onOpen={(item) => { + addFromCatalog(item); + go({ kind: "detail", domain: item.domain }); + }} + onAddCustom={() => go({ kind: "custom" })} + /> + )} + + )} +
+
+ + {route.kind === "custom" ? ( + { + if (!next) go({ kind: "browse" }); + }} + onAdd={(integration) => { + addIntegration(integration); + go({ kind: "detail", domain: integration.domain }); + }} + /> + ) : null} + + {route.kind === "auth" && open ? ( + { + if (!next) go({ kind: "detail", domain: open.item.domain }); + }} + integrationName={open.item.name} + domain={open.item.domain} + accountLabel={ + open.accounts.find((account) => account.status === "needs-auth")?.label ?? "default" + } + onAuthenticated={() => { + const account = + open.accounts.find((candidate) => candidate.status === "needs-auth")?.label ?? + "default"; + markConnected( + open.item.domain, + account, + `rhys@${open.item.domain.split(".")[0] ?? open.item.domain}`, + ); + go({ kind: "detail", domain: open.item.domain }); + }} + /> + ) : null} +
+ ); +} diff --git a/packages/onboarding-demo/src/screens/add-account-modal.tsx b/packages/onboarding-demo/src/screens/add-account-modal.tsx new file mode 100644 index 000000000..c4194122a --- /dev/null +++ b/packages/onboarding-demo/src/screens/add-account-modal.tsx @@ -0,0 +1,318 @@ +// Reproduction of packages/react/src/components/add-account-modal.tsx. +// +// The credential path is a 2-step wizard behind a horizontally scrolling strip +// of auth-method tabs. Step 1 is the key plus an optional read-only probe; +// step 2 is the display name and the "Connection saved to" owner picker. The +// OAuth path is not a wizard and shows an app picker instead. +// +// The word "Personal" is reproduced at every site it occurs on the real screen: +// the derived connection name, the callable-name hint, the owner picker's label +// and its selected option. + +import { useState } from "react"; +import { EyeIcon, EyeOffIcon, PlusIcon } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; +import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; +import { cn } from "@executor-js/react/lib/utils"; +import type { DemoAuthMethod } from "../fixtures"; + +function StepHeader(props: { readonly label: string; readonly hint?: string }) { + return ( +
+ + {props.hint ? {props.hint} : null} +
+ ); +} + +function PlacementLine(props: { readonly placement: DemoAuthMethod["placements"][number] }) { + const { placement } = props; + const lead = + placement.carrier === "header" + ? `${placement.name || "Authorization"}: ` + : placement.carrier === "env" + ? `${placement.name || "TOKEN"}=` + : `?${placement.name || "api_key"}=`; + return ( + + {lead} + {placement.prefix ? ( + {placement.prefix} + ) : null} + •••••• + + ); +} + +export function AddAccountModal(props: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly integrationName: string; + readonly methods: readonly DemoAuthMethod[]; + /** Start on the wizard's second step, which is the screen the owner picker + * and its repeated "Personal" live on. */ + readonly initialStep?: "validate" | "place"; + /** Reproduce the post-OAuth stuck state: the popup was closed, and the + * button never leaves "Connecting…". */ + readonly stuckConnecting?: boolean; + readonly onAdded: () => void; +}) { + const [methodId, setMethodId] = useState(props.methods[0]?.id ?? ""); + const [wizardStep, setWizardStep] = useState<"validate" | "place">( + props.initialStep ?? "validate", + ); + const [secret, setSecret] = useState(""); + const [revealed, setRevealed] = useState(false); + const [label, setLabel] = useState(""); + const [owner, setOwner] = useState<"user" | "org">("user"); + + const method = props.methods.find((candidate) => candidate.id === methodId) ?? props.methods[0]; + const isOAuth = method?.kind === "oauth"; + const wizardActive = !isOAuth; + const showValidateStep = !wizardActive || wizardStep === "validate"; + const showPlaceStep = !wizardActive || wizardStep === "place"; + + const ownerWord = owner === "user" ? "Personal" : "Workspace"; + const derivedName = `${ownerWord} ${props.integrationName}`; + const callableName = (label.trim() || derivedName).toLowerCase().replace(/[^a-z0-9]+/g, "_"); + + const singlePlacement = method?.placements[0]; + const affix = + singlePlacement && singlePlacement.carrier === "header" + ? `${singlePlacement.name}: ${singlePlacement.prefix}` + : null; + + return ( + + + + + Add connection · {props.integrationName} + {wizardActive ? ( + + Step {wizardStep === "validate" ? 1 : 2} of 2 + + ) : null} + + + A connection is a saved way to use this integration, owned by you or the workspace. + + + +
+ {showValidateStep && ( + { + setMethodId(next); + setWizardStep("validate"); + }} + className="w-full min-w-0 max-w-full gap-0" + > + +
+ {props.methods.map((candidate) => ( +
+ + {candidate.label} + +
+ ))} + +
+
+ + + {method && !isOAuth && method.placements.length > 0 && !affix ? ( +
+ {method.placements.map((placement, index) => ( + + ))} +
+ ) : null} + +
+ + + {isOAuth ? ( +
+

No app for {props.integrationName} yet

+

+ None of your registered apps target this integration's OAuth endpoint. + Register one to connect. +

+
+ +
+
+ ) : affix ? ( +
+ + + {affix} + + + {/* oxlint-disable-next-line react/forbid-elements */} + setSecret(event.target.value)} + className="min-w-0 flex-1 bg-transparent px-3 outline-none" + /> + +
+ ) : ( +
+ setSecret((event.target as HTMLInputElement).value)} + className="font-mono" + /> + +
+ )} +
+ + {!isOAuth && ( +
+ +
+ + + Pick an operation… + users_me + + organizations_list + + projects_list + + +
+
+ )} +
+
+ )} + + {showPlaceStep && ( + <> +
+ + setLabel((event.target as HTMLInputElement).value)} + /> +

+ This connection will be callable as{" "} + {callableName}. +

+
+ +
+ + setOwner(event.target.value === "org" ? "org" : "user")} + > + Personal + Workspace + +
+ + )} +
+ + + + {isOAuth ? ( + + ) : wizardStep === "validate" ? ( + + ) : ( + <> + + + + )} + +
+
+ ); +} diff --git a/packages/onboarding-demo/src/screens/add-openapi-page.tsx b/packages/onboarding-demo/src/screens/add-openapi-page.tsx new file mode 100644 index 000000000..9c52e5bdd --- /dev/null +++ b/packages/onboarding-demo/src/screens/add-openapi-page.tsx @@ -0,0 +1,261 @@ +// Reproduction of packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx +// in its post-analyze state, plus the AuthMethodListEditor block from +// packages/react/src/components/auth-method-list-editor.tsx. +// +// The locked "Method 1" panel is reproduced exactly: LockIcon, muted fill, +// `cursor-not-allowed select-none`, and a masked `Authorization: Bearer ••••••` +// placement line. It is a read-only echo of the spec's declared auth scheme — +// it takes no input — but it is the only credential-shaped thing on the page, +// and it appears at the moment the user has just copied an API key. + +import { useState } from "react"; +import { LockIcon, XIcon, ChevronDownIcon } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; +import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; +import type { DemoPreset } from "../fixtures"; + +function PlacementLine(props: { + readonly carrier: "header" | "query"; + readonly name: string; + readonly prefix: string; +}) { + const lead = props.carrier === "header" ? `${props.name}: ` : `?${props.name}=`; + return ( + + {lead} + {props.prefix ? {props.prefix} : null} + •••••• + + ); +} + +function FieldLabel(props: { readonly children: React.ReactNode }) { + return ( + + ); +} + +export function AddOpenApiPage(props: { + readonly preset: DemoPreset | null; + readonly onCancel: () => void; + readonly onComplete: () => void; +}) { + const [adding, setAdding] = useState(false); + const [overridesOpen, setOverridesOpen] = useState(false); + const [healthCheckOpen, setHealthCheckOpen] = useState(false); + const name = props.preset?.name ?? "API"; + const slug = props.preset?.id ?? "api"; + + return ( +
+
+
+

Add OpenAPI integration

+
+ + {/* Details — OpenApiIntegrationDetailsFields */} + + +
+ {props.preset?.icon ? ( + + ) : ( + + )} +
+

{name}

+

1.0.0 · 214 operations · 31 tags

+
+
+
+
+ Integration ID + +
+
+ Display name + +
+
+ Description + +
+
+ Base URL override (optional) + +

+ Overrides the spec's servers; leave empty to choose the server (and + variables) per tool call. +

+
+
+ OpenAPI spec + +
+
+
+
+ + {/* SpecOverridesEditor — collapsed */} +
+ {/* oxlint-disable-next-line react/forbid-elements */} + + {overridesOpen && ( +
+ [] +
+ )} +
+ + {/* AuthMethodListEditor */} +
+
+

+ Authentication +

+ +
+ +
+
+
+ + + Method 1 + + +
+
+

+ API key +

+
+
+ +
+
+

+ Pulled from spec. Remove to override. +

+
+
+ +
+
+ + + Method 2 + + +
+
+

+ OAuth +

+
+
+ Authorize + + https://us.posthog.com/oauth/authorize + +
+
+ Token + + https://us.posthog.com/oauth/token + +
+
+

+ Pulled from spec. Remove to override. +

+
+
+
+ +

+ Every method here is registered with the integration. Connect an account from the + integration page after adding. +

+
+ + {/* AddOpenApiHealthCheckSection */} +
+ {/* oxlint-disable-next-line react/forbid-elements */} + + {healthCheckOpen && ( +
+ Operation + +
+ )} +
+ + {/* FloatActions */} + +
+ + +
+
+
+
+ ); +} diff --git a/packages/onboarding-demo/src/screens/connect-dialog.tsx b/packages/onboarding-demo/src/screens/connect-dialog.tsx new file mode 100644 index 000000000..ba3788a62 --- /dev/null +++ b/packages/onboarding-demo/src/screens/connect-dialog.tsx @@ -0,0 +1,279 @@ +// Reproduction of the ConnectDialog in packages/react/src/pages/integrations.tsx. +// +// Faithful to the parts that shape the experience: one input that silently +// switches between URL-detect and preset-filter, plugin chips above the list, +// a fixed 16rem scroll window over ~30 curated presets in plugin order, and +// the integrations.sh long tail that only appears once you have typed two +// characters. Picking anything leaves the dialog for a full-page form. + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Badge } from "@executor-js/react/components/badge"; +import { Skeleton } from "@executor-js/react/components/skeleton"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryActions, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryTitle, +} from "@executor-js/react/components/card-stack"; +import { + catalogLogoUrl, + curatedPresets, + integrationPlugins, + searchCatalog, + type CatalogEntry, + type DemoPreset, + type PluginKey, +} from "../fixtures"; + +const CATALOG_KIND_LABEL: Record = { + mcp: "MCP", + openapi: "OpenAPI", + graphql: "GraphQL", +}; + +const looksLikeUrl = (raw: string): boolean => { + const v = raw.trim(); + if (v.length === 0) return false; + if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true; + if (v.includes("/")) return true; + if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true; + return false; +}; + +const SEARCH_DEBOUNCE_MS = 250; +const MIN_QUERY_LENGTH = 2; + +function useCatalogSearch(rawQuery: string) { + const query = rawQuery.trim().toLowerCase(); + const [state, setState] = useState<{ + readonly entries: readonly CatalogEntry[]; + readonly loading: boolean; + }>({ entries: [], loading: false }); + const generation = useRef(0); + + useEffect(() => { + const requestId = ++generation.current; + if (query.length < MIN_QUERY_LENGTH) { + setState({ entries: [], loading: false }); + return; + } + setState((previous) => ({ ...previous, loading: true })); + const timer = setTimeout(() => { + void searchCatalog(query).then((entries) => { + if (generation.current !== requestId) return; + setState({ entries, loading: false }); + }); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [query]); + + return state; +} + +export function ConnectDialog(props: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onPickPreset: (preset: DemoPreset) => void; + readonly onPickPlugin: (pluginKey: PluginKey) => void; + readonly onPickCatalogEntry: (entry: CatalogEntry) => void; +}) { + const [query, setQuery] = useState(""); + const isUrl = looksLikeUrl(query); + const presetSearch = isUrl ? "" : query; + + const filtered = useMemo(() => { + const q = presetSearch.trim().toLowerCase(); + if (q.length === 0) return curatedPresets; + return curatedPresets.filter(({ name, summary, pluginLabel }) => + `${name} ${summary} ${pluginLabel}`.toLowerCase().includes(q), + ); + }, [presetSearch]); + + const catalog = useCatalogSearch(presetSearch); + const showCatalogSection = + presetSearch.trim().length > 0 && (catalog.entries.length > 0 || catalog.loading); + + const close = () => { + setQuery(""); + props.onOpenChange(false); + }; + + return ( + { + if (!open) close(); + else props.onOpenChange(open); + }} + > + + + Connect an integration + + Search the preset library, or paste a URL to auto-detect. + + + +
+
+
+ setQuery((event.target as HTMLInputElement).value)} + placeholder="Search or paste a URL…" + className="flex-1" + /> + {isUrl && } +
+
+ +
+

Or add manually

+
+ {integrationPlugins.map((plugin) => ( + // oxlint-disable-next-line react/forbid-elements + + ))} +
+
+ +
+

Popular integrations

+ + + {filtered.length === 0 && !showCatalogSection ? ( +
+

No matching integrations

+

+ Paste a URL above to auto-detect, or pick an integration type manually. +

+
+ ) : ( + filtered.map((preset) => ( + + {/* oxlint-disable-next-line react/forbid-elements */} + + + )) + )} + + {showCatalogSection && ( + <> + {catalog.entries.map((entry) => ( + + {/* oxlint-disable-next-line react/forbid-elements */} + + + ))} + {catalog.loading && + catalog.entries.length === 0 && + Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} + + )} +
+
+
+
+
+
+ ); +} diff --git a/packages/onboarding-demo/src/screens/integration-detail.tsx b/packages/onboarding-demo/src/screens/integration-detail.tsx new file mode 100644 index 000000000..f503c3eaa --- /dev/null +++ b/packages/onboarding-demo/src/screens/integration-detail.tsx @@ -0,0 +1,105 @@ +// Reproduction of packages/react/src/pages/integration-detail.tsx plus the +// AccountsSection from packages/react/src/components/accounts-section.tsx. +// +// This is where a just-added integration lands. It has an Edit / Refresh / +// Delete header, an Accounts / Tools tab strip, a "Connections" heading, and a +// dashed empty card whose CTA is "Add connection" — the second add, for a thing +// the user did not know was separate from the first. + +import { useState } from "react"; +import { Button } from "@executor-js/react/components/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; +import type { DemoIntegration } from "../fixtures"; + +export function IntegrationDetailPage(props: { + readonly integration: DemoIntegration; + readonly onAddConnection: () => void; +}) { + const [tab, setTab] = useState<"accounts" | "tools">("accounts"); + + return ( +
+
+
+

+ {props.integration.name} +

+ + {props.integration.toolCount} tools + +
+
+ + + +
+
+ + setTab(value === "tools" ? "tools" : "accounts")} + className="min-h-0 flex-1 gap-0 overflow-hidden" + > +
+ + Accounts + Tools + +
+ + +
+
+
+

+ Connections +

+
+
+

No connections yet

+

+ Add a connection to make this integration's tools available. +

+ +
+
+
+
+ + +
+
+

No tools yet

+
+
+
+

No tools yet

+

+ Add a connection to unlock this integration's tools. +

+ +
+
+
+
+
+
+ ); +} diff --git a/packages/onboarding-demo/src/screens/integrations-page.tsx b/packages/onboarding-demo/src/screens/integrations-page.tsx new file mode 100644 index 000000000..f99bc2d75 --- /dev/null +++ b/packages/onboarding-demo/src/screens/integrations-page.tsx @@ -0,0 +1,142 @@ +// Reproduction of packages/react/src/pages/integrations.tsx — the screen that +// follows CLI setup. The "Connect an agent" card sits above the fold; finding +// an integration is a dialog behind one button in the header. + +import { PlusIcon } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { PageContainer, PageHeader } from "@executor-js/react/components/page"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryActions, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryTitle, + CardStackHeader, +} from "@executor-js/react/components/card-stack"; +import { Badge } from "@executor-js/react/components/badge"; +import { Tabs, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; +import type { DemoIntegration } from "../fixtures"; + +function McpInstallCard() { + return ( + + + + Remote HTTP + Standard I/O + + } + > +
+ Connect an agent + + Paste this into Claude Code, Cursor, or any MCP client, and your agent gets every tool + you connect here. + +
+
+ +
+
+ npx add-mcp http://localhost:3123/mcp --transport http --name executor +
+
Advanced ⌄
+
+
+ Work with your agent + Cursor + Claude + OpenCode + and more +
+
+
+
+ ); +} + +export function IntegrationsPage(props: { + readonly integrations: readonly DemoIntegration[]; + readonly onConnect: () => void; + readonly onOpenIntegration: (slug: string) => void; +}) { + return ( + + + + Connect + + } + /> + +
+ +
+ +
+ + {props.integrations.length === 0 ? ( +
+
+ +
+

No integrations yet

+

+ Connect an integration to start curating tools. +

+ +
+ ) : ( +
+ + + {props.integrations.map((integration) => ( + + {/* oxlint-disable-next-line react/forbid-elements */} + + + ))} + + +
+ )} + + ); +} diff --git a/packages/onboarding-demo/src/screens/next/authenticate.tsx b/packages/onboarding-demo/src/screens/next/authenticate.tsx new file mode 100644 index 000000000..63460645c --- /dev/null +++ b/packages/onboarding-demo/src/screens/next/authenticate.tsx @@ -0,0 +1,229 @@ +// Reworked: authenticating one account. +// +// The whole screen is built from the registry's own credential record, which +// integrations.sh already returns and the console currently discards: +// credentials[].label → what this key is called at the provider +// credentials[].generateUrl → the page that mints it, as a real link +// credentials[].setup → how to mint it, in the provider's own terms +// +// So the three questions the current modal leaves unanswered — what kind of key, +// where do I get one, where does it go — are answered on the screen that asks +// for it. The paste field is the first focusable thing and is never gated: a +// key already on the clipboard can go in immediately. + +import { useEffect, useState } from "react"; +import { ArrowUpRightIcon, EyeIcon, EyeOffIcon } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { Label } from "@executor-js/react/components/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { fetchSurfaceCredentials, type SurfaceCredential } from "../../fixtures"; + +/** The registry's setup text is markdown. Only links and bold appear in + * practice, so render those two and leave the rest as text rather than + * pulling in a markdown pipeline for a prototype. */ +function SetupText(props: { readonly text: string }) { + const nodes = props.text + .split(/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`)/g) + .filter((chunk) => chunk.length > 0) + .map((chunk, index) => { + const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(chunk); + if (link) { + return ( + + {link[1]} + + ); + } + const bold = /^\*\*([^*]+)\*\*$/.exec(chunk); + if (bold) { + return ( + + {bold[1]} + + ); + } + const code = /^`([^`]+)`$/.exec(chunk); + if (code) { + return ( + + {code[1]} + + ); + } + return {chunk}; + }); + return

{nodes}

; +} + +export function AuthenticateDialog(props: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly integrationName: string; + readonly domain: string; + readonly accountLabel: string; + readonly onAuthenticated: () => void; +}) { + const [credentials, setCredentials] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [secret, setSecret] = useState(""); + const [revealed, setRevealed] = useState(false); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let live = true; + void fetchSurfaceCredentials(props.domain).then((loaded) => { + if (!live) return; + setCredentials(loaded); + setSelectedId(loaded[0]?.id ?? null); + }); + return () => { + live = false; + }; + }, [props.domain]); + + const selected = credentials?.find((candidate) => candidate.id === selectedId) ?? null; + const isOAuth = selected?.type === "oauth2"; + const canSubmit = isOAuth || secret.trim().length > 0; + + return ( + + + + Connect {props.integrationName} + + Signing in as {props.accountLabel}. + + + + {credentials === null ? ( +

+ Checking how {props.domain} signs in… +

+ ) : credentials.length === 0 ? ( +

+ The registry has no credential record for {props.domain} yet. +

+ ) : ( +
+ {/* Only shown when the provider genuinely offers a choice. One way + in renders no picker at all. */} + {credentials.length > 1 && ( +
+ {credentials.map((credential) => ( + // oxlint-disable-next-line react/forbid-elements + + ))} +
+ )} + + {isOAuth ? ( +

+ {props.integrationName} signs in through your browser. Nothing to paste. +

+ ) : ( + <> +
+
+ + {selected?.generateUrl ? ( + + Get a key + + + ) : null} +
+
+ {/* oxlint-disable-next-line react/forbid-elements */} + setSecret(event.target.value)} + placeholder="Paste your key" + 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" + /> + +
+
+ + {selected?.setup ? ( +
+ +
+ ) : null} + + )} +
+ )} + + + + + +
+
+ ); +} diff --git a/packages/onboarding-demo/src/screens/next/browse.tsx b/packages/onboarding-demo/src/screens/next/browse.tsx new file mode 100644 index 000000000..0b3cee8d2 --- /dev/null +++ b/packages/onboarding-demo/src/screens/next/browse.tsx @@ -0,0 +1,234 @@ +// Reworked: the picker. +// +// Not a dialog behind a header button — the page itself. Search is the first +// thing on it, categories are chips under that, and every row adds in place: +// the row flips to "Added", the list does not move, and you can keep adding. +// Nothing here asks for a spec URL, a base URL or an auth method, because the +// registry already knows all three. +// +// "Add custom" sits next to the search box at all times, and the empty-search +// state leads with it rather than apologising — a URL that isn't in the +// registry is a normal thing to have, not a failed search. + +import { useEffect, useMemo, useState } from "react"; +import { CheckIcon, PlusIcon, SearchIcon } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Skeleton } from "@executor-js/react/components/skeleton"; +import { cn } from "@executor-js/react/lib/utils"; +import { CATEGORY_ORDER, loadCatalog, searchItems, type CatalogItem } from "../../catalog"; + +const SECTION_SIZE = 6; + +function AddButton(props: { readonly added: boolean; readonly onAdd: () => void }) { + if (props.added) { + return ( + + + Added + + ); + } + return ( + + ); +} + +function CatalogRow(props: { + readonly item: CatalogItem; + readonly added: boolean; + readonly onAdd: () => void; + readonly onOpen: () => void; +}) { + return ( +
+ {/* oxlint-disable-next-line react/forbid-elements */} + + +
+ ); +} + +/** Offered whenever the query cannot be satisfied from the registry — either + * because it looks like a URL, or because nothing matched. */ +function CustomPrompt(props: { readonly query: string; readonly onAddCustom: () => void }) { + const looksLikeUrl = /^[a-z]+:\/\//i.test(props.query) || props.query.includes("/"); + return ( +
+ + + +
+

+ {looksLikeUrl ? "Add this URL directly" : "Not in the registry?"} +

+

+ Point executor at any MCP server, OpenAPI spec, or GraphQL endpoint. +

+
+ +
+ ); +} + +export function BrowsePage(props: { + readonly addedDomains: readonly string[]; + readonly onAdd: (item: CatalogItem) => void; + readonly onOpen: (item: CatalogItem) => void; + readonly onAddCustom: () => void; +}) { + const [items, setItems] = useState(null); + const [query, setQuery] = useState(""); + const [category, setCategory] = useState("All"); + + useEffect(() => { + let live = true; + void loadCatalog().then((loaded) => { + if (live) setItems(loaded); + }); + return () => { + live = false; + }; + }, []); + + const searched = useMemo(() => (items ? searchItems(items, query) : []), [items, query]); + const searching = query.trim().length > 0; + + const sections = useMemo(() => { + if (searching) return []; + const wanted = category === "All" ? CATEGORY_ORDER : [category]; + return wanted + .map((name) => ({ + name, + items: searched.filter((item) => item.category === name).slice(0, SECTION_SIZE), + })) + .filter((section) => section.items.length > 0); + }, [searched, category, searching]); + + return ( + <> +
+
+ + setQuery((event.target as HTMLInputElement).value)} + placeholder="Search integrations…" + aria-label="Search integrations" + className="h-11 pl-9 text-sm" + /> +
+ +
+ +
+ {["All", ...CATEGORY_ORDER].map((name) => ( + // oxlint-disable-next-line react/forbid-elements + + ))} +
+ + {items === null ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) : searching ? ( +
+ {searched.slice(0, 40).map((item) => ( + props.onAdd(item)} + onOpen={() => props.onOpen(item)} + /> + ))} +
+ +
+
+ ) : ( +
+ {sections.map((section) => ( +
+

+ {section.name} +

+
+ {section.items.map((item) => ( + props.onAdd(item)} + onOpen={() => props.onOpen(item)} + /> + ))} +
+
+ ))} +
+ )} + + ); +} diff --git a/packages/onboarding-demo/src/screens/next/custom.tsx b/packages/onboarding-demo/src/screens/next/custom.tsx new file mode 100644 index 000000000..ebb3368a2 --- /dev/null +++ b/packages/onboarding-demo/src/screens/next/custom.tsx @@ -0,0 +1,242 @@ +// Reworked: adding something the registry has never heard of. +// +// This is the case the picker cannot cover and executor exists for — an +// internal API, a private MCP server, a spec behind a VPN. It has to be a +// first-class door, not the fallback you reach after search disappoints. +// +// The rule that keeps it simple: paste one URL, we say what we found, you add +// it. What comes out the other side is an ordinary added integration with an +// ordinary "Needs auth" account — identical to a registry pick from that point +// on, because the only thing the registry was ever providing is the definition. + +import { useState } from "react"; +import { ArrowUpRightIcon } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { Label } from "@executor-js/react/components/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { getDomain } from "tldts"; +import { logoFor, type AddedIntegration } from "../../catalog"; +import { INTEGRATIONS_SH_ORIGIN } from "../../fixtures"; + +type Kind = "mcp" | "openapi" | "graphql"; + +const KIND_LABEL: Record = { + mcp: "MCP server", + openapi: "OpenAPI spec", + graphql: "GraphQL endpoint", +}; + +interface Detected { + readonly kind: Kind; + readonly domain: string; + readonly url: string; + /** A registry entry related to this URL, if there is one. `exact` separates + * two genuinely different situations that a string match cannot tell apart + * on its own: the host IS the registry entry, versus the host merely sits + * under a domain the registry knows. `mcp.posthog.com` under `posthog.com` + * is probably the same service; `internal.acme.com` under `acme.com` is + * probably not, so the copy must not claim it is. */ + readonly registryDomain: string | null; + readonly exact: boolean; +} + +const guessKind = (url: URL): Kind => { + const path = `${url.pathname}${url.search}`.toLowerCase(); + if (path.includes("graphql")) return "graphql"; + if (/\/mcp\b|\/sse\b/.test(path)) return "mcp"; + if (/openapi|swagger|\.ya?ml$|\.json$/.test(path)) return "openapi"; + // A bare origin is most often an MCP server these days; the detect step in + // the real product probes rather than guesses. + return path === "/" || path.length === 0 ? "mcp" : "openapi"; +}; + +const detect = async (raw: string): Promise => { + const trimmed = raw.trim(); + const withScheme = /^[a-z][a-z0-9+\-.]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + const parsed = ((): URL | null => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL parsing reports failure by throwing + try { + return new URL(withScheme); + } catch { + return null; + } + })(); + if (!parsed) return { error: "That doesn't look like a URL." }; + + const host = parsed.hostname.replace(/^www\./, ""); + // The registry keys on the registrable domain, so an endpoint living on a + // subdomain (`mcp.posthog.com`) only matches once we also try its parent. + const candidates = [...new Set([host, getDomain(host)])].filter( + (candidate): candidate is string => candidate !== null && candidate.length > 0, + ); + const registryDomain = await (async () => { + for (const candidate of candidates) { + // A miss and a network failure mean the same thing here — nothing extra + // to offer — so both just move on to the next candidate. + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch reports transport failure by rejecting + try { + const response = await fetch( + `${INTEGRATIONS_SH_ORIGIN}/api/${encodeURIComponent(candidate)}/surface`, + ); + if (response.ok) return candidate; + } catch { + continue; + } + } + return null; + })(); + + return { + kind: guessKind(parsed), + domain: host, + url: parsed.toString(), + registryDomain, + exact: registryDomain === host, + }; +}; + +export function AddCustomDialog(props: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onAdd: (integration: AddedIntegration) => void; +}) { + const [raw, setRaw] = useState(""); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const run = async () => { + setBusy(true); + setError(null); + const outcome = await detect(raw); + setBusy(false); + if ("error" in outcome) { + setError(outcome.error); + setResult(null); + return; + } + setResult(outcome); + }; + + return ( + + + + Add a custom integration + + Point at an MCP server, an OpenAPI spec, or a GraphQL endpoint. It does not need to be + in the registry. + + + +
+
+ +
+ {/* oxlint-disable-next-line react/forbid-elements */} + { + setRaw(event.target.value); + setResult(null); + setError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter" && raw.trim().length > 0) void run(); + }} + placeholder="https://internal.acme.com/mcp" + 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" + /> + +
+ {error ?

{error}

: null} +
+ + {result ? ( +
+
+ +
+

{result.domain}

+

{KIND_LABEL[result.kind]}

+
+
+ {result.registryDomain ? ( +

+ {result.exact + ? `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.` + : `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.`}{" "} + + See the entry + + +

+ ) : ( +

+ Not in the registry. You'll set up its credential yourself on the next + screen. +

+ )} +
+ ) : null} +
+ + + + + +
+
+ ); +} diff --git a/packages/onboarding-demo/src/screens/next/integration-detail.tsx b/packages/onboarding-demo/src/screens/next/integration-detail.tsx new file mode 100644 index 000000000..3dec26dee --- /dev/null +++ b/packages/onboarding-demo/src/screens/next/integration-detail.tsx @@ -0,0 +1,209 @@ +// Reworked: one added integration. +// +// "Integration" and "connection" still exist — an integration can hold several +// accounts, which is a real capability — but the hierarchy is no longer +// something you have to understand before you can start. An add always leaves +// exactly one account row behind, already named, in the one state that matters: +// Needs auth, with the button that fixes it. Tools stay collapsed until there +// are some. +// +// A custom integration renders through this same screen. The only difference it +// is allowed to make is the one line naming where its definition came from. + +import { ArrowLeftIcon, ArrowUpRightIcon, ChevronDownIcon, PlusIcon } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { cn } from "@executor-js/react/lib/utils"; +import type { AddedIntegration } from "../../catalog"; + +export interface DemoAccount { + readonly label: string; + readonly status: "needs-auth" | "connected"; + readonly identity?: string; +} + +function AccountRow(props: { readonly account: DemoAccount; readonly onAuthenticate: () => void }) { + const needsAuth = props.account.status === "needs-auth"; + return ( +
+ + + + {props.account.identity ?? props.account.label} + + + {needsAuth ? ( + <> + Needs auth + + + ) : ( + Connected + )} +
+ ); +} + +export function NextIntegrationDetail(props: { + readonly item: AddedIntegration; + readonly accounts: readonly DemoAccount[]; + readonly toolCount: number; + readonly onBack: () => void; + readonly onAuthenticate: (accountLabel: string) => void; + readonly onAddAccount: () => void; + readonly onRemove: () => void; +}) { + const custom = props.item.source === "custom"; + return ( + <> + + +
+ +
+

{props.item.name}

+ {custom ? ( +

+ {props.item.url} +

+ ) : ( + + {props.item.domain} + + + )} +
+ +
+ +

{props.item.description}

+ +
+

+ Accounts +

+
+
+ {props.accounts.map((account) => ( + props.onAuthenticate(account.label)} + /> + ))} +
+ {/* oxlint-disable-next-line react/forbid-elements */} + +
+
+ +
+
+ + {props.toolCount === 0 + ? "Tools appear once an account is connected" + : `${props.toolCount} tools`} + + +
+
+ + ); +} + +/** The installed list. Without a sidebar this is how you get back to something + * you added, so it carries the same status vocabulary as the detail screen. */ +export function InstalledList(props: { + readonly items: readonly { + readonly item: AddedIntegration; + readonly accounts: readonly DemoAccount[]; + }[]; + readonly onOpen: (domain: string) => void; + readonly onBrowse: () => void; +}) { + if (props.items.length === 0) { + return ( +
+

Nothing added yet

+

+ Add an integration and it shows up here. +

+ +
+ ); + } + + return ( +
+ {props.items.map(({ item, accounts }) => { + const pending = accounts.filter((account) => account.status === "needs-auth").length; + return ( + // oxlint-disable-next-line react/forbid-elements + + ); + })} +
+ ); +} diff --git a/packages/onboarding-demo/src/shell.tsx b/packages/onboarding-demo/src/shell.tsx new file mode 100644 index 000000000..7104a39f6 --- /dev/null +++ b/packages/onboarding-demo/src/shell.tsx @@ -0,0 +1,97 @@ +// Reproduction of the console chrome (packages/app/src/web/shell.tsx): the +// wordmark bar, the five nav items, the second INTEGRATIONS heading that +// repeats the first nav item, and the four-link footer. + +import type { ReactNode } from "react"; +import { cn } from "@executor-js/react/lib/utils"; +import type { DemoIntegration } from "./fixtures"; + +const NAV_ITEMS = ["Integrations", "Secrets", "Policies", "Toolkits", "Artifacts"] as const; + +function NavItem(props: { readonly label: string; readonly active: boolean }) { + return ( + + {props.label} + + ); +} + +export function Shell(props: { + readonly integrations: readonly DemoIntegration[]; + readonly children: ReactNode; +}) { + return ( +
+ + +
+ {props.children} +
+
+ ); +} diff --git a/packages/onboarding-demo/src/step-state.ts b/packages/onboarding-demo/src/step-state.ts new file mode 100644 index 000000000..f7f639cfc --- /dev/null +++ b/packages/onboarding-demo/src/step-state.ts @@ -0,0 +1,45 @@ +// Which screens carry which workspace state. Jumping straight to a late step +// has to bring that step's state with it, or the screen is not the one the +// step is about. + +import type { StepId } from "./flow"; +import { gmailIntegration, posthogIntegration, type DemoIntegration } from "./fixtures"; + +export type AccountModal = "none" | "credential" | "place" | "oauth-stuck"; +export type ScreenView = "integrations" | "add" | "detail"; + +export interface StepState { + readonly integrations: readonly DemoIntegration[]; + readonly connectOpen: boolean; + readonly accountModal: AccountModal; + readonly view: ScreenView; +} + +const EMPTY: StepState = { + integrations: [], + connectOpen: false, + accountModal: "none", + view: "integrations", +}; + +const WITH_POSTHOG: StepState = { + integrations: [posthogIntegration], + connectOpen: false, + accountModal: "none", + view: "detail", +}; + +export const stepState: Readonly> = { + "integrations-empty": EMPTY, + "connect-dialog": { ...EMPTY, connectOpen: true }, + "add-openapi": { ...EMPTY, view: "add" }, + "detail-accounts": WITH_POSTHOG, + "add-account-credential": { ...WITH_POSTHOG, accountModal: "credential" }, + "add-account-place": { ...WITH_POSTHOG, accountModal: "place" }, + "oauth-stuck": { + ...WITH_POSTHOG, + integrations: [gmailIntegration], + accountModal: "oauth-stuck", + }, + "integrations-populated": { ...WITH_POSTHOG, view: "integrations" }, +}; diff --git a/packages/onboarding-demo/src/styles.css b/packages/onboarding-demo/src/styles.css new file mode 100644 index 000000000..4860fd2e0 --- /dev/null +++ b/packages/onboarding-demo/src/styles.css @@ -0,0 +1,5 @@ +/* The console's real stylesheet — same tokens, same primitives. The extra + @source is this package's own sources, which the react package's globs + (rooted at packages/react/src) do not cover. */ +@import "@executor-js/react/globals.css"; +@source "./**/*.{ts,tsx}"; diff --git a/packages/onboarding-demo/tsconfig.json b/packages/onboarding-demo/tsconfig.json new file mode 100644 index 000000000..8269f3f91 --- /dev/null +++ b/packages/onboarding-demo/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/packages/onboarding-demo/vite.config.ts b/packages/onboarding-demo/vite.config.ts new file mode 100644 index 000000000..7d364fd3c --- /dev/null +++ b/packages/onboarding-demo/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +// Standalone SPA reproduction of the current add-integration flow. No executor +// API, no auth, no backend — every screen runs off `src/fixtures.ts` plus the +// live public integrations.sh catalog, so the flow can be redesigned without +// standing up a tenant. +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { port: 5199 }, +}); diff --git a/packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx b/packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx index c68a96d5e..c30bc0d17 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx @@ -15,6 +15,7 @@ import { type AuthMethodRow, type AuthMethodSeed, } from "@executor-js/react/components/auth-method-list-editor"; +import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { addIntegrationErrorMessage, @@ -42,13 +43,25 @@ export default function AddGraphqlIntegration(props: { onComplete: (slug?: string) => void; onCancel: () => void; initialUrl?: string; + initialAuthHeader?: string; + initialAuthNote?: string; }) { const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); const [description, setDescription] = useState(""); const identity = useIntegrationIdentity({ fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "", }); - const authMethodList = useAuthMethodList(NO_SEEDS); + // GraphQL has no add-time detection, but the registry can declare the + // credential placement ("Authorization: {api_key}" — Linear's no-Bearer + // personal keys). A declared placement seeds the method list the same way a + // spec's security scheme would. + const registrySeeds = useMemo(() => { + const placement = props.initialAuthHeader + ? placementFromHeaderPattern(props.initialAuthHeader) + : null; + return placement ? [{ value: { kind: "apikey", placements: [placement] } }] : NO_SEEDS; + }, [props.initialAuthHeader]); + const authMethodList = useAuthMethodList(registrySeeds); const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); @@ -144,9 +157,13 @@ export default function AddGraphqlIntegration(props: { list={authMethodList} allowedKinds={["none", "apikey"]} emptyHint="No authentication declared. Add a method, or add the integration without auth and connect an account from the integration page later." - footerHint="Every method here is registered with the integration. Connect an account from the integration page after adding." + footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page." /> + {props.initialAuthNote ? ( +

{props.initialAuthNote}

+ ) : null} + {slugAlreadyExists && !adding && } {addError && } diff --git a/packages/plugins/graphql/src/react/integration-plugin.ts b/packages/plugins/graphql/src/react/integration-plugin.ts index 99d5823bf..9ee5cb112 100644 --- a/packages/plugins/graphql/src/react/integration-plugin.ts +++ b/packages/plugins/graphql/src/react/integration-plugin.ts @@ -1,6 +1,49 @@ -import { lazy } from "react"; -import type { IntegrationPlugin } from "@executor-js/sdk/client"; +import { lazy, useCallback } from "react"; +import { useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; +import type { + IntegrationPlugin, + IntegrationQuickAddInput, + IntegrationQuickAddResult, +} from "@executor-js/sdk/client"; +import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; +import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; +import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { graphqlPresets } from "../sdk/presets"; +import { createGraphqlIntegrationOptimistic } from "./atoms"; +import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config"; + +/** One-click add for a registry GraphQL row. A GraphQL endpoint cannot + * describe its own auth, so the registry's declared header pattern is the + * only carrier of facts like Linear's no-Bearer key — it becomes the + * declared method, exactly as the add page would seed it. */ +function useGraphqlQuickAdd(): ( + input: IntegrationQuickAddInput, +) => Promise { + const doAdd = useAtomSet(createGraphqlIntegrationOptimistic, { mode: "promiseExit" }); + return useCallback( + async (input) => { + const slug = slugifyNamespace(input.slug ?? input.name); + if (!slug) return { ok: false, reason: "no derivable slug" }; + const placement = input.authHeader ? placementFromHeaderPattern(input.authHeader) : null; + const authenticationTemplate = placement + ? graphqlAuthMethodInputsFromPlacements([placement]) + : []; + const exit = await doAdd({ + payload: { + endpoint: input.url, + slug, + name: input.name, + ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}), + }, + reactivityKeys: integrationWriteKeys, + }); + if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" }; + return { ok: true, slug: String(exit.value.slug) }; + }, + [doAdd], + ); +} const importAdd = () => import("./AddGraphqlIntegration"); const importAccounts = () => import("./GraphqlAccountsPanel"); @@ -12,9 +55,10 @@ export const graphqlIntegrationPlugin: IntegrationPlugin = { label: "GraphQL", add: lazy(importAdd), accounts: lazy(importAccounts), - presets: graphqlPresets, + presets: graphqlPresets.map((preset) => ({ ...preset, registryListed: true })), preload: () => { void importAdd(); void importAccounts(); }, + useQuickAdd: useGraphqlQuickAdd, }; diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 1dc0ffa3f..2dc605a04 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -36,6 +36,11 @@ const AddRemoteServerPayload = Schema.Struct({ description: Schema.optional(Schema.String), endpoint: Schema.String, remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), + /** Pin legacy protocol negotiation for a server that echoes the modern + * revision but breaks its contract (the probe reports when only legacy + * worked). Omitting this here silently stripped the pin from the stored + * integration, leaving it unusable against exactly that server. */ + versionNegotiation: Schema.optional(Schema.Literals(["auto", "legacy"])), slug: Schema.optional(Schema.String), queryParams: Schema.optional(StringMap), headers: Schema.optional(StringMap), @@ -94,6 +99,12 @@ const ProbeEndpointResponse = Schema.Struct({ serverName: Schema.NullOr(Schema.String), /** Server `instructions` from initialize — prefills the description field. */ instructions: Schema.NullOr(Schema.String), + /** Which protocol negotiation worked, when discovery succeeded. `legacy` + * means the server echoes the modern revision but breaks its contract, and + * the add must pin `versionNegotiation: "legacy"` on the integration — + * omitting this field here silently stripped it from the HTTP response and + * the pin never happened. */ + versionNegotiation: Schema.optional(Schema.Literals(["auto", "legacy"])), }); // --------------------------------------------------------------------------- diff --git a/packages/plugins/mcp/src/api/handlers.test.ts b/packages/plugins/mcp/src/api/handlers.test.ts index 2399104d9..512aaffc8 100644 --- a/packages/plugins/mcp/src/api/handlers.test.ts +++ b/packages/plugins/mcp/src/api/handlers.test.ts @@ -120,4 +120,37 @@ describe("McpHandlers", () => { expect(body.message).toContain("Do you need to provide an API key"); }), ); + + it.effect("the remote add carries versionNegotiation through to the extension", () => + Effect.gen(function* () { + // The probe's legacy retry is pointless if the pin dies at the HTTP + // boundary: this exact field was silently stripped by the payload + // schema and the handler's explicit field map. + let received: unknown; + const web = yield* webHandlerFor({ + ...failingExtension, + addServer: (input) => { + received = input; + return Effect.succeed({ slug: "pinned", tools: [] } as never); + }, + }); + const response = yield* Effect.promise(() => + (web.handler as (request: Request) => Promise)( + new Request("http://localhost/mcp/servers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + transport: "remote", + name: "Echoing server", + endpoint: "https://example.com/mcp", + versionNegotiation: "legacy", + authenticationTemplate: [{ kind: "none" }], + }), + }), + ), + ); + expect(response.status).toBeLessThan(500); + expect((received as { versionNegotiation?: string }).versionNegotiation).toBe("legacy"); + }), + ); }); diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 9c57da78e..91c55077e 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -67,6 +67,7 @@ const toServerInput = ( description?: string; endpoint: string; remoteTransport?: "streamable-http" | "sse" | "auto"; + versionNegotiation?: "auto" | "legacy"; queryParams?: Record; headers?: Record; slug?: string; @@ -83,6 +84,7 @@ const toServerInput = ( description: p.description, endpoint: p.endpoint, remoteTransport: p.remoteTransport, + versionNegotiation: p.versionNegotiation, queryParams: p.queryParams, headers: p.headers, slug: p.slug, diff --git a/packages/plugins/mcp/src/react/AddMcpIntegration.tsx b/packages/plugins/mcp/src/react/AddMcpIntegration.tsx index 8da424617..b2a6c5e37 100644 --- a/packages/plugins/mcp/src/react/AddMcpIntegration.tsx +++ b/packages/plugins/mcp/src/react/AddMcpIntegration.tsx @@ -37,10 +37,15 @@ import { import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import type { McpAuthMethodInput } from "../sdk/types"; import { probeMcpEndpoint, addMcpServer } from "./atoms"; +import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; -import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; +import { + mcpAuthMethodInputFromEditorValue, + mcpDetectedAuthSeeds, + mcpWireAuthInput, +} from "./auth-method-config"; import CodexPluginAdd from "./CodexPluginAdd"; import { parseStdioArgs } from "./stdio-fields"; import { isProbableMcpEndpoint } from "./probe-url"; @@ -79,6 +84,8 @@ type ProbeResult = { toolCount: number | null; serverName: string | null; instructions: string | null; + /** "legacy" when only the legacy handshake worked (version-echoing server). */ + versionNegotiation?: "auto" | "legacy"; }; type State = @@ -161,6 +168,9 @@ export default function AddMcpIntegration(props: { onCancel: () => void; initialUrl?: string; initialPreset?: string; + initialAuthHeader?: string; + initialAuthNote?: string; + initialAuthKind?: string; /** Whether the stdio transport is enabled on the server. */ allowStdio?: boolean; }) { @@ -219,29 +229,16 @@ export default function AddMcpIntegration(props: { // The probe seeds the method list: detected OAuth → an OAuth row; a 401 // without OAuth metadata → a bearer-header row; an open server → a no-auth // row. The user can edit any row or add alternate methods alongside. - const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => { - if (!probe) return []; - if (probe.requiresOAuth) { - return [ - { - value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }, - label: "Detected", - }, - ]; - } - if (probe.requiresAuthentication) { - return [ - { - value: { - kind: "apikey", - placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], - }, - label: "Detected", - }, - ]; - } - return [{ value: { kind: "none" }, label: "Detected" }]; - }, [probe]); + const authMethodSeeds: readonly AuthMethodSeed[] = useMemo( + () => + mcpDetectedAuthSeeds(probe, { + placement: props.initialAuthHeader + ? placementFromHeaderPattern(props.initialAuthHeader) + : null, + kind: props.initialAuthKind, + }), + [probe, props.initialAuthHeader, props.initialAuthKind], + ); const authMethodList = useAuthMethodList(authMethodSeeds); const remoteIdentity = useIntegrationIdentity({ @@ -337,6 +334,12 @@ export default function AddMcpIntegration(props: { ...(slug ? { slug } : {}), ...(headers ? { headers } : {}), authenticationTemplate, + // The probe reports when only legacy negotiation worked (a server + // that echoes the modern revision but breaks its contract); pin it + // so refreshes and tool calls use the same handshake. + ...(probe?.versionNegotiation === "legacy" + ? { versionNegotiation: "legacy" as const } + : {}), }, reactivityKeys: integrationWriteKeys, }); @@ -496,10 +499,14 @@ export default function AddMcpIntegration(props: { title="How does this server authenticate?" oauthMetadata="discovered" emptyHint="No methods declared. Add a method, or add the server without auth and connect from the integration page later." - footerHint="Every method here is registered with the server. Connect an account from the integration page after adding." + footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page." /> )} + {probe && props.initialAuthNote ? ( +

{props.initialAuthNote}

+ ) : null} + {/* Error (add server). Probe errors show inline on the field. */} {otherError && (
diff --git a/packages/plugins/mcp/src/react/auth-method-config.ts b/packages/plugins/mcp/src/react/auth-method-config.ts index 0675967a2..bd93babe1 100644 --- a/packages/plugins/mcp/src/react/auth-method-config.ts +++ b/packages/plugins/mcp/src/react/auth-method-config.ts @@ -9,6 +9,7 @@ import { AuthTemplateSlug } from "@executor-js/sdk/shared"; import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; +import type { AuthMethodSeed } from "@executor-js/react/components/auth-method-list-editor"; import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; import { authMethodFromSharedTemplate, @@ -126,3 +127,55 @@ export function mcpAuthMethodInputsFromPlacements( if (wire.length === 0) return []; return [{ kind: "apikey", placements: wire }]; } + +/** The auth methods a registry-driven MCP add declares before any editing: + * the probe's detection plus the registry's declared facts. ONE policy for + * both the add page (as editor seeds) and the one-click quick add (mapped + * straight to wire inputs) — two copies drifted is how the picker and the + * add page end up declaring different methods for the same server. */ +export function mcpDetectedAuthSeeds( + probe: { + readonly requiresOAuth: boolean; + readonly requiresAuthentication: boolean; + } | null, + registry: { + readonly placement?: Placement | null; + readonly kind?: string | undefined; + }, +): readonly AuthMethodSeed[] { + const registryPlacement = registry.placement ?? null; + if (!probe) { + // No probe result (pending, or the server was unreachable from here). + // The registry's declared facts still stand: an authless server or a + // known header pattern seeds the list the probe would have produced. + if (registryPlacement) return [{ value: { kind: "apikey", placements: [registryPlacement] } }]; + if (registry.kind === "none") return [{ value: { kind: "none" } }]; + return []; + } + if (probe.requiresOAuth) { + const oauth: AuthMethodSeed = { + value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }, + label: "Detected", + }; + // GitHub's MCP server takes a PAT bearer header in clients without + // OAuth; when the registry declared that placement, offer it alongside. + return registryPlacement + ? [oauth, { value: { kind: "apikey", placements: [registryPlacement] } }] + : [oauth]; + } + if (probe.requiresAuthentication) { + // The registry's exact placement beats the generic Bearer guess. + return [ + { + value: { + kind: "apikey", + placements: [ + registryPlacement ?? { carrier: "header", name: "Authorization", prefix: "Bearer " }, + ], + }, + label: "Detected", + }, + ]; + } + return [{ value: { kind: "none" }, label: "Detected" }]; +} diff --git a/packages/plugins/mcp/src/react/integration-plugin.tsx b/packages/plugins/mcp/src/react/integration-plugin.tsx index 3d4573046..172f136bf 100644 --- a/packages/plugins/mcp/src/react/integration-plugin.tsx +++ b/packages/plugins/mcp/src/react/integration-plugin.tsx @@ -1,6 +1,21 @@ -import { lazy, type ComponentProps, type ComponentType } from "react"; -import type { IntegrationPlugin } from "@executor-js/sdk/client"; +import { lazy, useCallback, type ComponentProps, type ComponentType } from "react"; +import { useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; +import type { + IntegrationPlugin, + IntegrationQuickAddInput, + IntegrationQuickAddResult, +} from "@executor-js/sdk/client"; +import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; +import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; +import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { mcpPresets } from "../sdk/presets"; +import { addMcpServer, probeMcpEndpoint } from "./atoms"; +import { + mcpAuthMethodInputFromEditorValue, + mcpDetectedAuthSeeds, + mcpWireAuthInput, +} from "./auth-method-config"; const importAdd = () => import("./AddMcpIntegration"); const importEditSheet = () => import("./EditMcpIntegration"); @@ -12,6 +27,51 @@ const LazyMcpAccountsPanel = lazy(importAccounts); type AddProps = ComponentProps; +/** One-click add for a registry MCP row: probe, declare the auth methods the + * add page would have seeded (same policy — `mcpDetectedAuthSeeds`), and + * register. A failed probe means the configuration screen's job — it renders + * the failure with retry UX — so quick add reports `{ok: false}` instead of + * guessing. */ +function useMcpQuickAdd(): (input: IntegrationQuickAddInput) => Promise { + const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); + const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" }); + return useCallback( + async (input) => { + const probeExit = await doProbe({ payload: { endpoint: input.url } }); + if (Exit.isFailure(probeExit)) return { ok: false, reason: "probe failed" }; + const probe = probeExit.value; + const seeds = mcpDetectedAuthSeeds(probe, { + placement: input.authHeader ? placementFromHeaderPattern(input.authHeader) : null, + kind: input.authKind, + }); + const methods = seeds.map((seed) => + mcpWireAuthInput(mcpAuthMethodInputFromEditorValue(seed.value)), + ); + const slug = input.slug ? slugifyNamespace(input.slug) : undefined; + const addExit = await doAddServer({ + payload: { + transport: "remote" as const, + name: input.name, + endpoint: input.url, + ...(slug ? { slug } : {}), + ...(probe.instructions ? { description: probe.instructions } : {}), + authenticationTemplate: methods.length > 0 ? methods : [{ kind: "none" as const }], + // The probe reports when only legacy negotiation worked (a server + // that echoes the modern revision but breaks its contract); pin it + // so refreshes and tool calls use the same handshake. + ...(probe.versionNegotiation === "legacy" + ? { versionNegotiation: "legacy" as const } + : {}), + }, + reactivityKeys: integrationWriteKeys, + }); + if (Exit.isFailure(addExit)) return { ok: false, reason: "add failed" }; + return { ok: true, slug: String(addExit.value.slug) }; + }, + [doProbe, doAddServer], + ); +} + export interface McpIntegrationPluginOptions { /** * Enable the stdio transport in the add-integration UI (tab + presets). @@ -32,11 +92,13 @@ export const createMcpIntegrationPlugin = ( ); - const presets = allowStdio + const base = allowStdio ? mcpPresets : mcpPresets.filter( (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"), ); + // Built-ins are registry-listed; a deployment's custom presets would not be. + const presets = base.map((p) => ({ ...p, registryListed: true })); return { key: "mcp", @@ -50,6 +112,7 @@ export const createMcpIntegrationPlugin = ( void importEditSheet(); void importAccounts(); }, + useQuickAdd: useMcpQuickAdd, }; }; diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index da7aa9185..d9713a997 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -634,13 +634,14 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); - // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable - // HTTP. SSE is a legacy-only transport; stdio negotiates per the - // integration's `versionNegotiation` (default legacy — see the stdio - // branch above). + // Auto-negotiate the 2026-07-28 era on Streamable HTTP unless the config + // pins `legacy` (for servers that echo the proposed revision and then + // violate its contract). SSE is a legacy-only transport; stdio negotiates + // per the integration's `versionNegotiation` (default legacy — see the + // stdio branch above). const connectStreamableHttp = connectClient({ transport: "streamable-http", - versionNegotiation: { mode: "auto" }, + ...(input.versionNegotiation === "legacy" ? {} : { versionNegotiation: { mode: "auto" } }), createTransport: (sdk) => new sdk.client.StreamableHTTPClientTransport(endpoint, { requestInit, diff --git a/packages/plugins/mcp/src/sdk/discover.ts b/packages/plugins/mcp/src/sdk/discover.ts index 193c1ec70..754333eb3 100644 --- a/packages/plugins/mcp/src/sdk/discover.ts +++ b/packages/plugins/mcp/src/sdk/discover.ts @@ -2,10 +2,11 @@ // MCP tool discovery — connect to an MCP server and list its tools // --------------------------------------------------------------------------- -import { Duration, Effect, Option, Predicate } from "effect"; +import { Duration, Effect, Option, Predicate, Schema } from "effect"; import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection"; import { McpToolDiscoveryError } from "./errors"; +import { createMcpConnector, type ConnectorInput } from "./connection"; import { httpStatusFromCause } from "./http-status"; import { decodeListToolsPage, @@ -34,6 +35,14 @@ const DEFAULT_DISCOVER_TIMEOUT = Duration.seconds(15); // Public API // --------------------------------------------------------------------------- +/** The SDK rejects with an Error subclass carrying a stable `code`; decode + * the boundary instead of stringifying an unknown. */ +const SdkFailure = Schema.Struct({ + code: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), +}); +const decodeSdkFailure = Schema.decodeUnknownOption(SdkFailure); + /** * List every tool from an open MCP connection, following `nextCursor` * pagination (spec: `tools/list` is a paginated operation — a single call @@ -68,14 +77,23 @@ const listAllTools = ( reauthorizationRequired: true, }); } + const failure = Option.getOrNull(decodeSdkFailure(cause)); + // A modern-era connection whose server breaks the modern response + // contract means the server echoed our proposed revision without + // implementing it; callers can retry with legacy negotiation. + const modernContractViolation = + connection.client.getProtocolEra?.() === "modern" && failure?.code === "INVALID_RESULT"; const httpStatus = httpStatusFromCause(cause); return new McpToolDiscoveryError({ stage: "list_tools", message: - httpStatus === undefined - ? "Failed listing MCP tools" - : `Failed listing MCP tools (HTTP ${httpStatus})`, + failure?.message !== undefined + ? `Failed listing MCP tools: ${failure.message.slice(0, 300)}` + : httpStatus === undefined + ? "Failed listing MCP tools" + : `Failed listing MCP tools (HTTP ${httpStatus})`, ...(httpStatus === undefined ? {} : { httpStatus }), + ...(modernContractViolation ? { modernContractViolation: true } : {}), }); }, }); @@ -103,6 +121,39 @@ const listAllTools = ( ); }); +/** Discovery that survives version-echoing servers. Some servers answer the + * proposed 2026-07-28 revision affirmatively while emitting 2024-era results + * (Walmart's MCP), which the modern client rightly rejects. On that exact + * signature, retry once with legacy negotiation and report which one worked, + * so an add flow can pin `versionNegotiation: "legacy"` on the integration. */ +export const discoverToolsFromInput = ( + input: ConnectorInput, + timeoutMs?: number, +): Effect.Effect< + { readonly manifest: McpToolManifest; readonly versionNegotiation: "auto" | "legacy" | null }, + McpToolDiscoveryError +> => + discoverTools(createMcpConnector(input), timeoutMs).pipe( + Effect.map((manifest) => ({ + manifest, + versionNegotiation: input.transport === "stdio" ? null : (input.versionNegotiation ?? "auto"), + })), + Effect.catch((error) => { + const retriable = + error.modernContractViolation === true && + input.transport !== "stdio" && + input.versionNegotiation !== "legacy"; + if (!retriable) return Effect.fail(error); + return discoverTools( + createMcpConnector({ ...input, versionNegotiation: "legacy" }), + timeoutMs, + ).pipe( + Effect.map((manifest) => ({ manifest, versionNegotiation: "legacy" as const })), + Effect.withSpan("mcp.discover.legacy_retry"), + ); + }), + ); + /** * Connect to an MCP server and discover all available tools. * Returns the parsed manifest containing server metadata and tool entries. diff --git a/packages/plugins/mcp/src/sdk/errors.ts b/packages/plugins/mcp/src/sdk/errors.ts index 64a89a690..dd14d2c5f 100644 --- a/packages/plugins/mcp/src/sdk/errors.ts +++ b/packages/plugins/mcp/src/sdk/errors.ts @@ -43,6 +43,11 @@ export class McpToolDiscoveryError extends Schema.TaggedErrorClass authenticationTemplate: input.authenticationTemplate ? normalizeMcpAuthMethods(input.authenticationTemplate) : [mcpAuthMethodFromShorthand(input.auth ?? { kind: "none" })], + versionNegotiation: input.versionNegotiation, }; }; @@ -674,6 +683,7 @@ const buildConnectorInput = ( authProvider, ...(authProvider === undefined ? {} : { staticOAuthBearer: true }), httpClientLayer, + versionNegotiation: config.versionNegotiation, }); }; @@ -928,17 +938,17 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { const probeHeaders = typeof input === "string" ? undefined : input.headers; const probeQueryParams = typeof input === "string" ? undefined : input.queryParams; - const connector = createMcpConnector({ + const result = yield* discoverToolsFromInput({ transport: "remote", endpoint: trimmed, headers: probeHeaders, queryParams: probeQueryParams, httpClientLayer, - }); - - const result = yield* discoverTools(connector).pipe( - Effect.map((m) => ({ ok: true as const, manifest: m })), - Effect.catch(() => Effect.succeed({ ok: false as const, manifest: null })), + }).pipe( + Effect.map((d) => ({ ok: true as const, ...d })), + Effect.catch(() => + Effect.succeed({ ok: false as const, manifest: null, versionNegotiation: null }), + ), Effect.withSpan("mcp.plugin.discover_tools"), ); @@ -953,6 +963,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { toolCount: result.manifest.tools.length, serverName: result.manifest.server?.name ?? null, instructions: result.manifest.server?.instructions ?? null, + ...(result.versionNegotiation === "legacy" + ? { versionNegotiation: "legacy" as const } + : {}), } satisfies McpProbeResult; } @@ -1395,10 +1408,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { template === null ? null : String(template), allowStdio, httpClientLayer, - ).pipe( - Effect.map((ci) => createMcpConnector(ci)), - Effect.result, - ); + ).pipe(Effect.result); if (Result.isFailure(built)) { return { @@ -1408,7 +1418,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { }; } - const discovered = yield* discoverTools(built.success).pipe( + const discovered = yield* discoverToolsFromInput(built.success).pipe( Effect.result, Effect.withSpan("mcp.plugin.discover_tools", { attributes: { "mcp.connection.name": String(connection.name) }, @@ -1431,7 +1441,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { : {}), }; } - return { tools: discovered.success.tools.map(toToolDef) }; + return { tools: discovered.success.manifest.tools.map(toToolDef) }; }).pipe( Effect.withSpan("mcp.plugin.resolve_tools", { attributes: { "mcp.connection.name": String(connection.name) }, @@ -1647,13 +1657,11 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { const name = parsed.value.hostname || "mcp"; const slug = deriveMcpNamespace({ endpoint: trimmed }); - const connector = createMcpConnector({ + const connected = yield* discoverToolsFromInput({ transport: "remote", endpoint: trimmed, httpClientLayer, - }); - - const connected = yield* discoverTools(connector).pipe( + }).pipe( Effect.map(() => true), Effect.catch(() => Effect.succeed(false)), Effect.withSpan("mcp.plugin.discover_tools"), @@ -1765,9 +1773,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { credential.template === null ? null : String(credential.template), allowStdio, options?.httpClientLayer ?? ctx.httpClientLayer, - ).pipe(Effect.map((ci) => createMcpConnector(ci))); + ); - return yield* discoverTools(connector).pipe( + return yield* discoverToolsFromInput(connector).pipe( Effect.map( () => ({ status: "healthy" as const, checkedAt: Date.now() }) satisfies HealthCheckResult, diff --git a/packages/plugins/mcp/src/sdk/presets.ts b/packages/plugins/mcp/src/sdk/presets.ts index e2a05fe26..71f1f3f98 100644 --- a/packages/plugins/mcp/src/sdk/presets.ts +++ b/packages/plugins/mcp/src/sdk/presets.ts @@ -57,14 +57,6 @@ const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map( })); export const mcpPresets: readonly McpPreset[] = [ - { - id: "emulate-mcp", - name: "Emulate MCP", - summary: "Deterministic MCP fixtures for validating native text and image content.", - url: "https://emulators.dev/mcp/query/mcp?token=demo-token", - endpoint: "https://emulators.dev/mcp/query/mcp?token=demo-token", - icon: "https://integrations.sh/logo/emulators.dev", - }, { id: "deepwiki", name: "DeepWiki", diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index 3b14a4ab3..ea4980b15 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -231,6 +231,12 @@ export const McpRemoteIntegrationConfig = Schema.Struct({ /** Declared auth methods — how a connection's values are rendered onto * requests. A connection's `template` picks one by slug. */ authenticationTemplate: Schema.Array(McpAuthMethod), + /** Protocol negotiation. Remote defaults to `auto` (2026-07-28 era). + * `legacy` pins servers that ECHO the proposed revision and then violate + * its response contract — Walmart's MCP answers "2026-07-28" to any + * proposal while emitting 2024-era results, which the modern client + * rightly rejects. */ + versionNegotiation: Schema.optional(McpStdioVersionNegotiation), }); export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type; diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 0b8077bc4..0ad634dc3 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -82,6 +82,9 @@ const AddSpecPayload = Schema.Struct({ name: Schema.optional(Schema.String), description: Schema.optional(Schema.String), baseUrl: Schema.optional(Schema.String), + /** The product's domain when the caller knew it (a registry row names + * notion.com) — display identity when the spec lives on a code host. */ + displayDomain: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), specFormat: Schema.optional(Schema.String), diff --git a/packages/plugins/openapi/src/api/handlers.ts b/packages/plugins/openapi/src/api/handlers.ts index b4e892d42..969170358 100644 --- a/packages/plugins/openapi/src/api/handlers.ts +++ b/packages/plugins/openapi/src/api/handlers.ts @@ -63,6 +63,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope name: payload.name, description: payload.description, baseUrl: payload.baseUrl, + displayDomain: payload.displayDomain, headers: payload.headers ? { ...payload.headers } : undefined, queryParams: payload.queryParams ? { ...payload.queryParams } : undefined, specFormat: payload.specFormat, diff --git a/packages/plugins/openapi/src/providers/google/discovery.test.ts b/packages/plugins/openapi/src/providers/google/discovery.test.ts index f48f70fc0..748f8bc91 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.test.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "@effect/vitest"; import { Effect, Option, Schema } from "effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; import { @@ -7,6 +8,7 @@ import { convertGoogleDiscoveryToOpenApi, isGoogleDiscoveryUrl, normalizeGoogleDiscoveryUrl, + fetchGoogleDiscoveryDocument, } from "./discovery"; import { googleOAuthConsentScopesForPreset } from "./service-policy"; import { extract, parse } from "@executor-js/plugin-openapi"; @@ -1669,3 +1671,29 @@ it.effect("marks People API semantic inputs as required", () => }); }), ); + +it("falls back to the service-hosted Discovery URL when the central directory has no entry", async () => { + // Google Ads is not listed in the central directory: normalization maps it + // onto www.googleapis.com for identity, and that URL 404s. The fetch must + // still succeed by falling back to the host the caller named. + const seen: string[] = []; + const stub = HttpClient.make((request) => { + seen.push(request.url); + return Effect.succeed( + request.url.includes("www.googleapis.com") + ? HttpClientResponse.fromWeb(request, new Response("nope", { status: 404 })) + : HttpClientResponse.fromWeb( + request, + new Response('{"name":"googleads"}', { status: 200 }), + ), + ); + }); + const text = await Effect.runPromise( + fetchGoogleDiscoveryDocument( + "https://googleads.googleapis.com/$discovery/rest?version=v24", + ).pipe(Effect.provideService(HttpClient.HttpClient, stub)), + ); + expect(text).toBe('{"name":"googleads"}'); + expect(seen[0]).toContain("www.googleapis.com"); + expect(seen[1]).toBe("https://googleads.googleapis.com/$discovery/rest?version=v24"); +}); diff --git a/packages/plugins/openapi/src/providers/google/discovery.ts b/packages/plugins/openapi/src/providers/google/discovery.ts index 87bf0c7d8..9d7907698 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.ts @@ -343,6 +343,25 @@ export const isGoogleDiscoveryUrl = (url: string): boolean => { return normalizeGoogleDiscoveryUrl(url) !== null; }; +/** The service's own Discovery endpoint, for a URL that named it. Normalization + * canonicalizes most services onto the central directory, which is right for + * identity but NOT universally fetchable: services outside the directory + * (Google Ads) answer only on their own host. */ +const serviceHostedDiscoveryUrl = (discoveryUrl: string): string | null => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() rejects malformed input + try { + const parsed = new URL(discoveryUrl.trim()); + const host = parsed.hostname.toLowerCase(); + if (host === "www.googleapis.com" || !host.endsWith(".googleapis.com")) return null; + if (!["/$discovery/rest", "/$discovery/rest/"].includes(parsed.pathname)) return null; + const version = parsed.searchParams.get("version")?.trim(); + if (!version || !DISCOVERY_VERSION_RE.test(version)) return null; + return `https://${host}/$discovery/rest?version=${version}`; + } catch { + return null; + } +}; + export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")( function* (discoveryUrl: string, credentials?: SpecFetchCredentials) { const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl); @@ -353,35 +372,50 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov }); } const client = yield* HttpClient.HttpClient; - const requestUrl = new URL(normalizedDiscoveryUrl); - for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { - requestUrl.searchParams.set(name, value); - } - let request = HttpClientRequest.get(requestUrl.toString()).pipe( - HttpClientRequest.setHeader("Accept", "application/json, */*"), - ); - for (const [name, value] of Object.entries(credentials?.headers ?? {})) { - request = HttpClientRequest.setHeader(request, name, value); - } - const response = yield* client.execute(request).pipe( - Effect.mapError( - () => - new OpenApiParseError({ - message: "Failed to fetch Google Discovery document", - }), - ), - ); - if (response.status < 200 || response.status >= 300) { - return yield* new OpenApiParseError({ - message: `Failed to fetch Google Discovery document: HTTP ${response.status}`, + + const attempt = (target: string) => + Effect.gen(function* () { + const requestUrl = new URL(target); + for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { + requestUrl.searchParams.set(name, value); + } + let request = HttpClientRequest.get(requestUrl.toString()).pipe( + HttpClientRequest.setHeader("Accept", "application/json, */*"), + ); + for (const [name, value] of Object.entries(credentials?.headers ?? {})) { + request = HttpClientRequest.setHeader(request, name, value); + } + const response = yield* client + .execute(request) + .pipe( + Effect.mapError( + () => new OpenApiParseError({ message: "Failed to fetch Google Discovery document" }), + ), + ); + if (response.status < 200 || response.status >= 300) { + return yield* new OpenApiParseError({ + message: `Failed to fetch Google Discovery document: HTTP ${response.status}`, + }); + } + return yield* response.text.pipe( + Effect.mapError( + () => + new OpenApiParseError({ message: "Failed to read Google Discovery document body" }), + ), + ); }); - } - return yield* response.text.pipe( - Effect.mapError( - () => - new OpenApiParseError({ - message: "Failed to read Google Discovery document body", - }), + + // Normalization maps a service-hosted URL onto the central directory for a + // STABLE IDENTITY, but the directory does not list every service — Google + // Ads answers only on googleads.googleapis.com, so the canonical form 404s + // for a URL the user pasted that works. Fall back to the host they named + // rather than maintaining an allowlist of every such service forever. + const serviceHosted = serviceHostedDiscoveryUrl(discoveryUrl); + return yield* attempt(normalizedDiscoveryUrl).pipe( + Effect.catch((error) => + serviceHosted && serviceHosted !== normalizedDiscoveryUrl + ? attempt(serviceHosted) + : Effect.fail(error), ), ); }, diff --git a/packages/plugins/openapi/src/providers/google/presets.ts b/packages/plugins/openapi/src/providers/google/presets.ts index 9591ae86b..de80393bb 100644 --- a/packages/plugins/openapi/src/providers/google/presets.ts +++ b/packages/plugins/openapi/src/providers/google/presets.ts @@ -346,6 +346,7 @@ export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets ...(preset.featured ? { featured: preset.featured } : {}), family: "google", specFormat: "google-discovery", + registryListed: true, defaultSlug: googleServiceSlug(preset.id), authTemplate: googleCatalogAuthTemplate(preset.id), ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}), diff --git a/packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts b/packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts index b8f9e2562..a5db2edf1 100644 --- a/packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts +++ b/packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts @@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { createExecutor, IntegrationSlug } from "@executor-js/sdk"; import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; import { openApiPlugin, parse } from "@executor-js/plugin-openapi"; +import { resolveSpecFormatAdapter } from "../../sdk/spec-format"; import type { AuthenticationInput } from "@executor-js/plugin-openapi"; import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; @@ -290,3 +291,30 @@ it.effect("preserves a Google preset's full consumer consent boundary when refre ); }), ); + +it.effect("a hand-pasted Discovery URL selects the adapter without a preset", () => + Effect.gen(function* () { + const detected = yield* resolveSpecFormatAdapter( + [googleDiscoveryAdapter], + undefined, + GMAIL_URL, + ); + expect(detected?.id).toBe("google-discovery"); + + const plainOpenApi = yield* resolveSpecFormatAdapter( + [googleDiscoveryAdapter], + undefined, + "https://api.example.com/openapi.json", + ); + expect(plainOpenApi).toBeNull(); + + // An explicit format id still wins over detection, and an unknown one + // still fails rather than silently falling back. + const explicit = yield* resolveSpecFormatAdapter( + [googleDiscoveryAdapter], + "google-discovery", + "https://api.example.com/openapi.json", + ); + expect(explicit?.id).toBe("google-discovery"); + }), +); diff --git a/packages/plugins/openapi/src/providers/google/spec-format-adapter.ts b/packages/plugins/openapi/src/providers/google/spec-format-adapter.ts index f7a5d1418..5c0d1faa5 100644 --- a/packages/plugins/openapi/src/providers/google/spec-format-adapter.ts +++ b/packages/plugins/openapi/src/providers/google/spec-format-adapter.ts @@ -6,6 +6,7 @@ import { type DerivedIdentity, type SpecFormatAdapter } from "../../sdk/spec-for import { convertGoogleDiscoveryBundleToOpenApi, fetchGoogleDiscoveryDocument, + isGoogleDiscoveryUrl, normalizeGoogleDiscoveryUrl, } from "./discovery"; @@ -47,6 +48,7 @@ export const deriveGoogleDiscoveryIdentity = (doc: unknown): DerivedIdentity | n export const googleDiscoveryAdapter: SpecFormatAdapter = { id: "google-discovery", + detectsUrl: isGoogleDiscoveryUrl, fetch: (input) => Effect.gen(function* () { const documents = yield* Effect.forEach( diff --git a/packages/plugins/openapi/src/providers/microsoft/presets.ts b/packages/plugins/openapi/src/providers/microsoft/presets.ts index 901eb61d4..e081df193 100644 --- a/packages/plugins/openapi/src/providers/microsoft/presets.ts +++ b/packages/plugins/openapi/src/providers/microsoft/presets.ts @@ -569,6 +569,7 @@ export const microsoftCatalog: readonly IntegrationPreset[] = microsoftGraphScop ...(preset.featured ? { featured: preset.featured } : {}), family: "microsoft", specFormat: "microsoft-graph", + registryListed: true, defaultSlug: microsoftServiceSlug(preset.id), authTemplate: microsoftGraphCatalogAuthTemplate(preset), ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}), diff --git a/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts b/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts index 9db13e8a0..b2be16bbc 100644 --- a/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts +++ b/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts @@ -221,3 +221,22 @@ it.effect("stream-previews a Graph selection without a whole-document parse", () ]); }), ); + +it("detects Graph sources so a hand-pasted URL does not fall to the plain path", () => { + const detects = (url: string) => microsoftGraphAdapter.detectsUrl?.(url) === true; + // The monolith is the case that matters: on the plain path it is 43 MB and + // trips the whole-document parse limit. + expect(detects(`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=mail`)).toBe(true); + expect(detects(MICROSOFT_GRAPH_OPENAPI_URL)).toBe(true); + expect( + detects( + "https://github.com/UsefulSoftwareCo/executor/releases/download/graph-slices/mail.yaml", + ), + ).toBe(true); + // Already-sliced hosted specs are self-contained and parse on the plain + // path; claiming them would route them through the slice builder for nothing. + expect(detects("https://integrations.sh/specs/microsoft-graph/mail.json")).toBe(false); + expect( + detects("https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json"), + ).toBe(false); +}); diff --git a/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts b/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts index 16c762dc0..aed66ead7 100644 --- a/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts +++ b/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts @@ -3,6 +3,7 @@ import { Effect } from "effect"; import type { SpecFormatAdapter } from "../../sdk/spec-format"; import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph"; +import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets"; import { microsoftGraphPresetIdsForSliceAsset, microsoftGraphSliceAssetFromUrl } from "./slices"; const fragmentPresetIds = (hash: string): readonly string[] => @@ -40,8 +41,28 @@ const graphCatalogSelection = ( }; }; +/** Recognizes the Graph sources this adapter exists to handle: Microsoft's + * published monolith and executor's slice assets, with or without a + * `#preset=` selector. Without this, pasting one of those URLs by hand falls + * to the plain OpenAPI path, which then chokes on the 43 MB monolith — the + * adapter only ever engaged when a preset supplied `specFormat`. */ +const detectsGraphUrl = (url: string): boolean => { + if (!URL.canParse(url)) return false; + const parsed = new URL(url); + parsed.hash = ""; + const bare = parsed.toString(); + return ( + bare === MICROSOFT_GRAPH_OPENAPI_URL || + microsoftGraphSliceAssetFromUrl(bare) !== null || + /^https:\/\/raw\.githubusercontent\.com\/microsoftgraph\/msgraph-metadata\/.*\/openapi\.ya?ml$/.test( + bare, + ) + ); +}; + export const microsoftGraphAdapter: SpecFormatAdapter = { id: "microsoft-graph", + detectsUrl: detectsGraphUrl, fetch: (input) => buildMicrosoftGraphOpenApiSpec( graphCatalogSelection(input.urls[0]), diff --git a/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx b/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx index 19c90b888..bb8e303a3 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx @@ -25,6 +25,7 @@ import { type AuthMethodRow, type AuthMethodSeed, } from "@executor-js/react/components/auth-method-list-editor"; +import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; import { FieldLabel } from "@executor-js/react/components/field"; import { FloatActions } from "@executor-js/react/components/float-actions"; @@ -170,18 +171,40 @@ export default function AddOpenApiIntegration(props: { initialUrl?: string; initialPreset?: string; initialNamespace?: string; + initialAuthHeader?: string; + initialAuthNote?: string; + initialSpecOverrides?: string; }) { const integrationPlugins = useIntegrationPlugins(); const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi"); const openApiPresets = openApiPlugin?.presets; - const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); + // A `?preset=` deep link is self-sufficient: the preset's own URL seeds the + // field. The connect dialog used to pass `&url=` alongside, which hid that + // this never worked on its own — preset links from agents and docs carry + // only the id. + const [specUrl, setSpecUrl] = useState( + () => + props.initialUrl ?? + openApiPresets?.find((preset) => preset.id === props.initialPreset)?.url ?? + "", + ); const [specOverridesDraft, setSpecOverridesDraft] = useState(null); const [analyzing, setAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl); const presetSpecOverrides = decodeOpenApiSpecOverrides(activePreset?.specOverrides); - const specOverridesText = specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides); + // Registry-declared overrides — how a vendor's published document gets + // improved without hosting a fork (Neon's console session cookies posing as + // security schemes). A preset's own overrides win; the user's draft wins + // over both, and the editor shows exactly what will apply. + const registrySpecOverrides = useMemo(() => { + if (!props.initialSpecOverrides) return undefined; + const parsed = parseSpecOverridesText(props.initialSpecOverrides); + return parsed.ok ? parsed.value : undefined; + }, [props.initialSpecOverrides]); + const specOverridesText = + specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides ?? registrySpecOverrides); // After analysis const [preview, setPreview] = useState(null); @@ -297,14 +320,26 @@ export default function AddOpenApiIntegration(props: { ...(preview?.headerPresets ?? []).map((preset) => preset.label), ...(preview?.oauth2Presets ?? []).map((preset) => preset.label), ]; - return authenticationTemplate.map( + const detected = authenticationTemplate.map( (template: Authentication, index: number): AuthMethodSeed => ({ value: editorValueFromAuthentication(template), slug: String(template.slug), ...(labels[index] !== undefined ? { label: labels[index] } : {}), }), ); - }, [preview, authenticationTemplate]); + if (preview === null) return detected; + // The registry's declared credential placement fills what the document + // leaves out. GitHub's official description famously declares no + // securitySchemes at all, and even with the OAuth preset template a PAT + // bearer header is how most calls actually authenticate — so the key + // method is offered alongside OAuth. A spec-declared key method wins over + // the registry's version of the same fact. + const placement = props.initialAuthHeader + ? placementFromHeaderPattern(props.initialAuthHeader) + : null; + if (!placement || detected.some((seed) => seed.value.kind === "apikey")) return detected; + return [...detected, { value: { kind: "apikey", placements: [placement] } }]; + }, [preview, authenticationTemplate, props.initialAuthHeader]); const authMethodList = useAuthMethodList(authMethodSeeds); // The methods to register, mapped back to stored `Authentication[]`. Drops @@ -597,10 +632,14 @@ export default function AddOpenApiIntegration(props: { )} + {preview && props.initialAuthNote ? ( +

{props.initialAuthNote}

+ ) : null} + {preview ? ( { + it("uses the same effective overrides for preview and add, preset first", () => { + const registryOverrides = decodeOpenApiSpecOverrides([ + { op: "remove", path: "/components/securitySchemes/Cookie" }, + ]); + const plan = quickAddSpecPlan( + { + id: "figma", + name: "Figma", + summary: "", + specFormat: "plain-ish", + specOverrides: [...scopeOverride], + }, + registryOverrides, + ); + // Preset overrides win over the registry's, mirroring the full add page. + expect(plan.specOverrides).toEqual(scopeOverride); + expect(plan.specFormat).toBe("plain-ish"); + }); + + it("an explicitly EMPTY preset override list suppresses the registry's", () => { + // Presence-based, like the full page's `presetOverrides ?? registry`: + // an empty list is a decision, not an absence. + const registryOverrides = decodeOpenApiSpecOverrides([ + { op: "remove", path: "/components/securitySchemes/Cookie" }, + ]); + const plan = quickAddSpecPlan( + { id: "custom", name: "Custom", summary: "", specOverrides: [] }, + registryOverrides, + ); + expect(plan.specOverrides).toBeUndefined(); + }); + + it("falls through to registry overrides when the preset has none", () => { + const registryOverrides = decodeOpenApiSpecOverrides([ + { op: "remove", path: "/components/securitySchemes/Cookie" }, + ]); + const plan = quickAddSpecPlan(undefined, registryOverrides); + expect(plan.specOverrides).toEqual(registryOverrides); + expect(plan.specFormat).toBeUndefined(); + }); +}); + +describe("composeQuickAddAuth", () => { + const registryPlacement = { + carrier: "header", + name: "Authorization", + prefix: "Bearer ", + } as const; + + it("keeps override-derived OAuth scopes and appends the registry key", () => { + // The preview reflects the OVERRIDDEN document (quickAddSpecPlan sends + // the same overrides to preview): its oauth preset carries the replaced + // scopes. The composed template must retain them verbatim and add the + // registry header only because no key method was detected. + const template = composeQuickAddAuth([], registryPlacement, { + headerPresets: [], + oauth2Presets: [ + { + label: "OAuth2", + securitySchemeName: "OAuth2", + flow: "authorizationCode" as const, + authorizationUrl: Option.some("https://example.com/authorize"), + tokenUrl: "https://example.com/token", + resource: Option.none(), + refreshUrl: Option.none(), + // The overridden document's scopes — the whole point: the preview + // reflects the SAME effective spec the add stores. + scopes: { "files:read": "" }, + identityScopes: [] as const, + }, + ], + servers: [{ url: "https://api.example.com" }], + }); + expect(template).toHaveLength(2); + const oauth = template.find((method) => "kind" in method && method.kind === "oauth2"); + expect(oauth && "scopes" in oauth ? oauth.scopes : undefined).toEqual(["files:read"]); + const key = template.find((method) => !("kind" in method) || method.kind !== "oauth2"); + expect(JSON.stringify(key)).toContain("Authorization"); + }); + + it("does not append the registry key when the spec already declares one", () => { + const template = composeQuickAddAuth([], registryPlacement, { + headerPresets: [ + { + label: "API key", + headers: { "X-API-Key": null }, + secretHeaders: ["X-API-Key"], + secretQueryParams: [], + }, + ], + oauth2Presets: [], + servers: [{ url: "https://api.example.com" }], + }); + expect(template).toHaveLength(1); + expect(JSON.stringify(template[0])).toContain("X-API-Key"); + }); +}); + +describe("quickAddRequestPayloads", () => { + it("the preview and the add carry the identical spec plan", () => { + // The regression contract itself: reverting either call site to a bare + // payload makes these fields diverge and this test fail. + const plan = quickAddSpecPlan( + { + id: "figma", + name: "Figma", + summary: "", + specFormat: "fmt", + specOverrides: [...scopeOverride], + }, + undefined, + ); + const requests = quickAddRequestPayloads( + { url: "https://example.com/spec.yaml", name: "Figma API", domain: "figma.com" }, + "figma_api", + plan, + ); + expect(requests.preview.specFormat).toBe("fmt"); + expect(requests.preview.specOverrides).toEqual(scopeOverride); + expect(requests.add.specFormat).toBe(requests.preview.specFormat); + expect(requests.add.specOverrides).toEqual(requests.preview.specOverrides); + expect(requests.add.displayDomain).toBe("figma.com"); + expect(requests.add.spec).toEqual({ kind: "url", url: "https://example.com/spec.yaml" }); + }); +}); + +describe("performQuickAdd", () => { + // The OPERATION under test, with the two mutations captured — the guard + // the review demanded twice: helper tests kept passing while a call site + // could quietly stop using the shared plan. + it("sends the identical spec plan to preview and add, and composed auth", async () => { + const previews: unknown[] = []; + const adds: unknown[] = []; + const deps: QuickAddDeps = { + presets: [ + { + id: "figma", + name: "Figma", + summary: "", + url: "https://example.com/spec.yaml", + specFormat: "fmt", + specOverrides: [...scopeOverride], + }, + ], + preview: (payload) => { + previews.push(payload); + return Promise.resolve( + Exit.succeed({ + headerPresets: [], + oauth2Presets: [ + { + label: "OAuth2", + securitySchemeName: "OAuth2", + flow: "authorizationCode" as const, + authorizationUrl: Option.some("https://example.com/authorize"), + tokenUrl: "https://example.com/token", + resource: Option.none(), + refreshUrl: Option.none(), + scopes: { "files:read": "" }, + identityScopes: [] as const, + }, + ], + servers: [{ url: "https://api.example.com" }], + }), + ); + }, + add: (payload) => { + adds.push(payload); + return Promise.resolve(Exit.succeed({ slug: "figma_api" })); + }, + }; + const result = await performQuickAdd(deps, { + url: "https://example.com/spec.yaml", + name: "Figma API", + slug: "figma-api", + domain: "figma.com", + authHeader: "Authorization: Bearer {token}", + }); + expect(result).toEqual({ ok: true, slug: "figma_api" }); + expect(previews).toHaveLength(1); + expect(adds).toHaveLength(1); + const preview = previews[0] as Record; + const add = adds[0] as Record; + // The regression contract: BOTH real payloads carry the one spec plan. + expect(preview.specFormat).toBe("fmt"); + expect(preview.specOverrides).toEqual(scopeOverride); + expect(add.specFormat).toBe(preview.specFormat); + expect(add.specOverrides).toEqual(preview.specOverrides); + expect(add.slug).toBe("figma_api"); + expect(add.displayDomain).toBe("figma.com"); + // Auth derived from the overridden document, registry key appended. + const template = add.authenticationTemplate as readonly unknown[]; + expect(JSON.stringify(template)).toContain("files:read"); + expect(JSON.stringify(template)).toContain("Authorization"); + }); + + it("skips the preview entirely when the registry declared no header", async () => { + const previews: unknown[] = []; + const deps: QuickAddDeps = { + presets: [], + preview: (payload) => { + previews.push(payload); + return Promise.resolve(Exit.fail("unexpected")); + }, + add: () => Promise.resolve(Exit.succeed({ slug: "plain" })), + }; + const result = await performQuickAdd(deps, { + url: "https://example.com/openapi.json", + name: "Plain API", + }); + expect(result).toEqual({ ok: true, slug: "plain" }); + expect(previews).toHaveLength(0); + }); +}); diff --git a/packages/plugins/openapi/src/react/integration-plugin.ts b/packages/plugins/openapi/src/react/integration-plugin.ts index d827f8083..51fb870b8 100644 --- a/packages/plugins/openapi/src/react/integration-plugin.ts +++ b/packages/plugins/openapi/src/react/integration-plugin.ts @@ -1,6 +1,234 @@ -import { lazy } from "react"; -import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client"; +import { lazy, useCallback } from "react"; +import { useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; +import type { + IntegrationPlugin, + IntegrationPreset, + IntegrationQuickAddInput, + IntegrationQuickAddResult, +} from "@executor-js/sdk/client"; +import { AuthTemplateSlug } from "@executor-js/sdk/shared"; +import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; +import { placementFromHeaderPattern, type Placement } from "@executor-js/react/lib/auth-placements"; +import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { openApiPresets } from "../sdk/presets"; +import { detectedAuthenticationTemplates } from "../sdk/derive-auth"; +import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; +import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config"; +import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides"; + +const normalizedSpecUrl = (url: string): string => { + if (!URL.canParse(url)) return url.trim().replace(/\/$/, ""); + const parsed = new URL(url); + parsed.hash = ""; + parsed.searchParams.sort(); + return parsed.toString().replace(/\/$/, ""); +}; + +/** The preset table still knows things no spec can say — GitHub's OAuth + * endpoints against a spec that declares NO security at all. A registry row + * whose URL is a preset's URL gets that knowledge pulled across. Matches the + * plugin's COMPLETE preset list, so a deployment's custom presets + * contribute too, not just the built-ins. */ +const presetForSpecUrl = ( + presets: readonly IntegrationPreset[], + url: string, +): IntegrationPreset | undefined => { + const target = normalizedSpecUrl(url); + return presets.find( + (preset) => preset.url !== undefined && normalizedSpecUrl(preset.url) === target, + ); +}; + +/** One-click add for a registry OpenAPI row. The spec itself is the + * configuration: omitting `authenticationTemplate` and `baseUrl` tells the + * server to derive both from the document, exactly what the add page's + * untouched defaults submit. Registry spec overrides ride along. */ + +/** The spec inputs a quick add must hold CONSISTENT between preview and add: + * deriving auth from a document other than the one being added is how a + * preset's scope overrides got silently dropped. Preset overrides win over + * the registry's, mirroring the full add page. */ +export const quickAddSpecPlan = ( + preset: IntegrationPreset | undefined, + registryOverrides: ReturnType, +): { + readonly specFormat?: string; + readonly specOverrides?: NonNullable>; +} => { + const presetOverrides = preset?.specOverrides + ? decodeOpenApiSpecOverrides(preset.specOverrides) + : undefined; + // PRESENCE-based, mirroring the full page's `presetOverrides ?? + // registryOverrides`: a preset declaring an explicitly EMPTY override list + // is a decision — suppress the registry's patches — not an absence. + const effective = presetOverrides !== undefined ? presetOverrides : registryOverrides; + return { + ...(preset?.specFormat ? { specFormat: preset.specFormat } : {}), + ...(effective && effective.length > 0 ? { specOverrides: effective } : {}), + }; +}; + +/** The preview and add requests built from ONE plan, so they cannot diverge: + * deriving auth from a different effective document than the one stored is + * the bug this module has now had twice. The add payload takes further + * fields (family, health check, auth template) AFTER this base. */ +export const quickAddRequestPayloads = ( + input: { readonly url: string; readonly name: string; readonly domain?: string }, + slug: string, + specPlan: ReturnType, +): { + readonly preview: { readonly spec: string } & ReturnType; + readonly add: { + readonly spec: { readonly kind: "url"; readonly url: string }; + readonly slug: string; + readonly name: string; + readonly displayDomain?: string; + } & ReturnType; +} => ({ + preview: { spec: input.url, ...specPlan }, + add: { + spec: { kind: "url", url: input.url }, + slug, + name: input.name, + ...(input.domain ? { displayDomain: input.domain } : {}), + ...specPlan, + }, +}); + +/** The full add page's method policy as one pure step: preset OAuth wins + * outright; else every preview-detected method is preserved and the + * registry's key placement is appended only when the detected set has no + * key method (GitHub declares no security at all). `preview` MUST be the + * summary of the same effective document the add will store. */ +export const composeQuickAddAuth = ( + presetMethods: readonly ReturnType[], + registryPlacement: Placement | null, + preview: { + readonly headerPresets: Parameters[0]; + readonly oauth2Presets: Parameters[1]; + readonly servers: readonly { readonly url: string }[]; + } | null, +): readonly ReturnType[] => { + if (presetMethods.length > 0) { + return registryPlacement + ? [...presetMethods, openApiWireAuthInput(templateFromPlacements([registryPlacement]))] + : presetMethods; + } + if (!registryPlacement || preview === null) return presetMethods; + const detected = detectedAuthenticationTemplates( + preview.headerPresets, + preview.oauth2Presets, + preview.servers[0]?.url ?? "", + ); + const detectedHasApiKey = detected.some((template) => template.kind === "apikey"); + return [ + ...detected.map(openApiWireAuthInput), + ...(detectedHasApiKey + ? [] + : [openApiWireAuthInput(templateFromPlacements([registryPlacement]))]), + ]; +}; + +export interface QuickAddDeps { + readonly presets: readonly IntegrationPreset[]; + /** The two mutations, injected so the OPERATION is testable against its + * actual outgoing payloads — helper-level tests kept passing while a call + * site quietly stopped using the shared plan. */ + readonly preview: ( + payload: ReturnType["preview"], + ) => Promise[2]>, unknown>>; + readonly add: ( + payload: ReturnType["add"] & { + readonly family?: string; + readonly healthCheck?: IntegrationPreset["healthCheck"]; + readonly authenticationTemplate?: ReturnType[]; + }, + ) => Promise>; +} + +/** The complete quick-add operation, pure of React: everything between the + * picker's click and the two API calls. */ +export const performQuickAdd = async ( + deps: QuickAddDeps, + input: IntegrationQuickAddInput, +): Promise => { + const { presets } = deps; + const slug = slugifyNamespace(input.slug ?? input.name); + if (!slug) return { ok: false, reason: "no derivable slug" }; + // Registry overrides arrive as untyped JSON; a malformed patch goes to + // the configuration screen (its editor renders the parse error) rather + // than being silently dropped from a "successful" quick add. + const specOverrides = input.specOverrides + ? decodeOpenApiSpecOverrides(input.specOverrides) + : undefined; + if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) { + return { ok: false, reason: "unparseable spec overrides" }; + } + const preset = presetForSpecUrl(presets, input.url); + const registryPlacement = input.authHeader ? placementFromHeaderPattern(input.authHeader) : null; + const presetMethods = (preset?.authTemplate ?? []).flatMap((template) => + template.kind === "oauth2" + ? [ + openApiWireAuthInput({ + ...template, + slug: AuthTemplateSlug.make(template.slug), + resource: template.resource ?? undefined, + }), + ] + : [], + ); + // ONE spec plan for both preview and add: deriving auth from a + // different effective document than the one stored is how a preset's + // scope overrides got silently dropped. + const specPlan = quickAddSpecPlan(preset, specOverrides); + const requests = quickAddRequestPayloads( + { url: input.url, name: input.name, ...(input.domain ? { domain: input.domain } : {}) }, + slug, + specPlan, + ); + let preview: Parameters[2] = null; + if (presetMethods.length === 0 && registryPlacement) { + // Composing with spec knowledge needs the spec: one preview call, + // only on this path (a plain registry row with no auth facts still + // adds with zero extra round trips). + const previewExit = await deps.preview(requests.preview); + if (Exit.isFailure(previewExit)) return { ok: false, reason: "preview failed" }; + preview = previewExit.value; + } + const authenticationTemplate = composeQuickAddAuth(presetMethods, registryPlacement, preview); + const exit = await deps.add({ + ...requests.add, + ...(preset?.family ? { family: preset.family } : {}), + ...(preset?.healthCheck ? { healthCheck: preset.healthCheck } : {}), + ...(authenticationTemplate.length > 0 + ? { authenticationTemplate: [...authenticationTemplate] } + : {}), + }); + if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" }; + return { ok: true, slug: String(exit.value.slug) }; +}; + +function makeUseQuickAdd(presets: readonly IntegrationPreset[]) { + return function useOpenApiQuickAdd(): ( + input: IntegrationQuickAddInput, + ) => Promise { + const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); + const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); + return useCallback( + (input) => + performQuickAdd( + { + presets, + preview: (payload) => doPreview({ payload }), + add: (payload) => doAdd({ payload, reactivityKeys: integrationWriteKeys }), + }, + input, + ), + [doAdd, doPreview], + ); + }; +} const importAdd = () => import("./AddOpenApiIntegration"); const importEditSheet = () => import("./UpdateSpecSection"); @@ -10,20 +238,27 @@ export interface OpenApiClientConfig { readonly presets?: readonly IntegrationPreset[]; } -export const createOpenApiIntegrationPlugin = ( - config?: OpenApiClientConfig, -): IntegrationPlugin => ({ - key: "openapi", - label: "OpenAPI", - add: lazy(importAdd), - editSheet: lazy(importEditSheet), - accounts: lazy(importAccounts), - presets: [...openApiPresets, ...(config?.presets ?? [])], - preload: () => { - void importAdd(); - void importEditSheet(); - void importAccounts(); - }, -}); +export const createOpenApiIntegrationPlugin = (config?: OpenApiClientConfig): IntegrationPlugin => { + // Built-ins are registry-listed (the picker shows the registry's card); + // a deployment's custom presets are not, and keep their own cards. + const presets: readonly IntegrationPreset[] = [ + ...openApiPresets.map((preset) => ({ ...preset, registryListed: true })), + ...(config?.presets ?? []), + ]; + return { + key: "openapi", + label: "OpenAPI", + add: lazy(importAdd), + editSheet: lazy(importEditSheet), + accounts: lazy(importAccounts), + presets, + preload: () => { + void importAdd(); + void importEditSheet(); + void importAccounts(); + }, + useQuickAdd: makeUseQuickAdd(presets), + }; +}; export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); diff --git a/packages/plugins/openapi/src/sdk/config.ts b/packages/plugins/openapi/src/sdk/config.ts index 55918fbaf..fdfc58093 100644 --- a/packages/plugins/openapi/src/sdk/config.ts +++ b/packages/plugins/openapi/src/sdk/config.ts @@ -52,6 +52,11 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ specUrl: Schema.optional(Schema.String), /** Optional base URL override. */ baseUrl: Schema.optional(Schema.String), + /** The PRODUCT's domain, recorded at add time when the caller knew it (a + * registry row names notion.com). The spec's own host is often a code + * host (raw.githubusercontent.com) that says nothing about the product, + * which sent credential guidance and favicons to the wrong domain. */ + displayDomain: Schema.optional(Schema.String), /** Static headers applied to every request (no secret material). */ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), /** Static query params applied to every request (no secret material). */ diff --git a/packages/plugins/openapi/src/sdk/derive-auth.test.ts b/packages/plugins/openapi/src/sdk/derive-auth.test.ts index 1b3aea903..a79c92839 100644 --- a/packages/plugins/openapi/src/sdk/derive-auth.test.ts +++ b/packages/plugins/openapi/src/sdk/derive-auth.test.ts @@ -71,3 +71,45 @@ describe("detectedAuthenticationTemplates", () => { ).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]); }); }); + +describe("query-located api keys", () => { + it("a query apiKey preset places the secret in the query string", () => { + const templates = detectedAuthenticationTemplates( + [ + { + label: "Legacy-API-key (query)", + headers: {}, + secretHeaders: [], + secretQueryParams: ["apiKey"], + }, + ], + [], + "https://example.com", + ); + expect(templates).toHaveLength(1); + const template = templates[0]; + expect(template?.kind).toBe("apikey"); + expect(template?.kind === "apikey" ? template.placements : []).toEqual([ + { carrier: "query", name: "apiKey" }, + ]); + }); + + it("a strategy mixing a header and a query param asks for two inputs", () => { + const templates = detectedAuthenticationTemplates( + [ + { + label: "exp-api-key + apiKey (query)", + headers: { "exp-api-key": null }, + secretHeaders: ["exp-api-key"], + secretQueryParams: ["apiKey"], + }, + ], + [], + "https://example.com", + ); + const template = templates[0]; + const placements = template?.kind === "apikey" ? template.placements : []; + expect(placements.map((placement) => placement.carrier)).toEqual(["header", "query"]); + expect(new Set(placements.map((placement) => placement.variable)).size).toBe(2); + }); +}); diff --git a/packages/plugins/openapi/src/sdk/derive-auth.ts b/packages/plugins/openapi/src/sdk/derive-auth.ts index 0f5176230..243cc7bab 100644 --- a/packages/plugins/openapi/src/sdk/derive-auth.ts +++ b/packages/plugins/openapi/src/sdk/derive-auth.ts @@ -104,20 +104,33 @@ const apiKeyTemplateFromHeaderPreset = ( preset: HeaderPreset, slug: AuthTemplateSlug, ): APIKeyAuthentication => { - const variables = variablesForHeaders(preset.secretHeaders); + const secretQueryParams = preset.secretQueryParams ?? []; + // One variable namespace across carriers, so a strategy sending two secrets + // (a header and a query param) asks for two distinct inputs. + const variables = variablesForHeaders([...preset.secretHeaders, ...secretQueryParams]); return { slug, kind: "apikey", - placements: preset.secretHeaders.map((headerName) => { - const prefix = headerPrefix(preset, headerName); - const variable = variables.get(headerName); - return { - carrier: "header" as const, - name: headerName, - ...(prefix ? { prefix } : {}), - ...(variable ? { variable } : {}), - }; - }), + placements: [ + ...preset.secretHeaders.map((headerName) => { + const prefix = headerPrefix(preset, headerName); + const variable = variables.get(headerName); + return { + carrier: "header" as const, + name: headerName, + ...(prefix ? { prefix } : {}), + ...(variable ? { variable } : {}), + }; + }), + ...secretQueryParams.map((paramName) => { + const variable = variables.get(paramName); + return { + carrier: "query" as const, + name: paramName, + ...(variable ? { variable } : {}), + }; + }), + ], }; }; diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index d92a2ae14..b0c055823 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -98,6 +98,10 @@ export interface OpenApiSpecConfig { * then the title). */ readonly description?: string; readonly baseUrl?: string; + /** The product's domain, when the caller knows it (a registry row names + * notion.com); display identity for guidance/favicons when the spec's + * own host is a code host. */ + readonly displayDomain?: string; /** Static headers applied to every request (no secret material). */ readonly headers?: Record; /** Static query params applied to every request. */ @@ -307,6 +311,7 @@ const AddIntegrationInputSchema = Schema.Struct({ name: Schema.optional(Schema.String), description: Schema.optional(Schema.String), baseUrl: Schema.optional(Schema.String), + displayDomain: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), specFormat: Schema.optional(Schema.String), @@ -603,7 +608,9 @@ export const describeOpenApiIntegrationDisplay = ( ): { readonly url?: string; readonly family?: string } => { const config = decodeOpenApiIntegrationConfig(record.config); return { - url: config?.baseUrl ?? config?.specUrl, + url: + config?.baseUrl ?? + (config?.displayDomain ? `https://${config.displayDomain}` : config?.specUrl), ...(config?.family ? { family: config.family } : {}), }; }; @@ -661,6 +668,7 @@ export const openApiPlugin = definePlugin< const adapter = yield* resolveSpecFormatAdapter( options?.specFormats ?? [], config.specFormat, + config.spec.kind === "url" ? config.spec.url : undefined, ); if (adapter) { if (config.spec.kind !== "url") { @@ -782,6 +790,7 @@ export const openApiPlugin = definePlugin< const adapter = yield* resolveSpecFormatAdapter( options?.specFormats ?? [], config.specFormat, + config.spec.kind === "url" ? config.spec.url : undefined, ); const derivedIdentity = adapter?.deriveIdentity && resolved.document @@ -865,6 +874,7 @@ export const openApiPlugin = definePlugin< // resolved per call from the operation's `servers` (extracted from // the spec), so we never bake a derived base URL into the config. ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), + ...(config.displayDomain ? { displayDomain: config.displayDomain } : {}), ...(config.headers ? { headers: config.headers } : {}), ...(config.queryParams ? { queryParams: config.queryParams } : {}), ...(config.specFormat ? { specFormat: config.specFormat } : {}), diff --git a/packages/plugins/openapi/src/sdk/presets.ts b/packages/plugins/openapi/src/sdk/presets.ts index a5ecc8a56..206c28436 100644 --- a/packages/plugins/openapi/src/sdk/presets.ts +++ b/packages/plugins/openapi/src/sdk/presets.ts @@ -145,7 +145,9 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ id: "axiom", name: "Axiom", summary: "Log ingestion, querying, datasets, and monitors.", - url: "https://axiom.co/docs/restapi/versions/v2.json", + // axiom.co/docs/restapi/versions/v2.json now 404s; the docs repo is where + // Axiom actually publishes the spec. + url: "https://raw.githubusercontent.com/axiomhq/docs/main/content/docs/%28api-reference%29/restapi/versions/v2.json", icon: "https://integrations.sh/logo/axiom.co", }, { @@ -169,13 +171,6 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml", icon: "https://integrations.sh/logo/digitalocean.com", }, - { - id: "petstore", - name: "Petstore", - summary: "Classic OpenAPI demo, no auth required.", - url: "https://petstore3.swagger.io/api/v3/openapi.json", - icon: "https://integrations.sh/logo/petstore3.swagger.io", - }, { id: "val-town", name: "Val Town", diff --git a/packages/plugins/openapi/src/sdk/preview.ts b/packages/plugins/openapi/src/sdk/preview.ts index 4266b0234..51279af8f 100644 --- a/packages/plugins/openapi/src/sdk/preview.ts +++ b/packages/plugins/openapi/src/sdk/preview.ts @@ -122,6 +122,9 @@ export const HeaderPreset = Schema.Struct({ headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)), /** Which headers should be stored as secrets */ secretHeaders: Schema.Array(Schema.String), + /** Query parameters the strategy sends the secret in (apiKey in=query, + * e.g. Viator's legacy `?apiKey=`). Absent on older stored previews. */ + secretQueryParams: Schema.optional(Schema.Array(Schema.String)), }); export type HeaderPreset = typeof HeaderPreset.Type; @@ -347,6 +350,7 @@ const buildHeaderPresets = ( const headers: Record = {}; const secretHeaders: string[] = []; + const secretQueryParams: string[] = []; const labelParts: string[] = []; for (const scheme of resolved) { @@ -363,8 +367,16 @@ const buildHeaderPresets = ( headers[headerName] = null; secretHeaders.push(headerName); labelParts.push(scheme.name); + } else if (scheme.type === "apiKey" && Option.getOrElse(scheme.in, () => "") === "query") { + secretQueryParams.push(Option.getOrElse(scheme.headerName, () => scheme.name)); + labelParts.push(`${scheme.name} (query)`); } else if (scheme.type === "apiKey") { - labelParts.push(`${scheme.name} (${Option.getOrElse(scheme.in, () => "unknown")})`); + // Cookie (and unknown) locations are not renderable as a stored + // method — auth placements carry header|query — and a cookie scheme + // is usually the vendor console's own session, not a mintable + // credential. Contributing a label here used to produce a method + // with zero placements: an empty, unfillable card in the add flow. + continue; } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") { return []; } else { @@ -372,21 +384,16 @@ const buildHeaderPresets = ( } } - if (Object.keys(headers).length === 0 && resolved.length > 0) { - return [ - HeaderPreset.make({ - label: labelParts.join(" + "), - headers: {}, - secretHeaders: [], - }), - ]; - } + // A strategy in which nothing is renderable (cookie-only, or an exotic + // scheme type) yields no preset rather than an empty one. + if (secretHeaders.length === 0 && secretQueryParams.length === 0) return []; return [ HeaderPreset.make({ label: labelParts.join(" + "), headers, secretHeaders, + ...(secretQueryParams.length > 0 ? { secretQueryParams } : {}), }), ]; }); diff --git a/packages/plugins/openapi/src/sdk/spec-format.ts b/packages/plugins/openapi/src/sdk/spec-format.ts index c42c8c286..cec32ce86 100644 --- a/packages/plugins/openapi/src/sdk/spec-format.ts +++ b/packages/plugins/openapi/src/sdk/spec-format.ts @@ -41,13 +41,24 @@ export interface SpecFormatAdapter { readonly id: string; readonly fetch: (input: SpecFetchInput) => Effect.Effect; readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null; + /** Recognizes URLs this adapter can convert. Lets a hand-pasted URL take + * the same path a preset would: without this, a Google Discovery URL only + * worked through a preset carrying `specFormat`, and pasting the identical + * URL failed as "not OpenAPI". */ + readonly detectsUrl?: (url: string) => boolean; } export const resolveSpecFormatAdapter = ( adapters: readonly SpecFormatAdapter[], id: string | undefined, + url?: string, ): Effect.Effect => { - if (!id) return Effect.succeed(null); + if (!id) { + if (!url) return Effect.succeed(null); + return Effect.succeed( + adapters.find((candidate) => candidate.detectsUrl?.(url) === true) ?? null, + ); + } const adapter = adapters.find((candidate) => candidate.id === id); if (adapter) return Effect.succeed(adapter); return Effect.fail(new OpenApiParseError({ message: `Unknown OpenAPI spec format: ${id}` })); diff --git a/packages/react/src/api/analytics.test.ts b/packages/react/src/api/analytics.test.ts index 97809adf8..c301d0c48 100644 --- a/packages/react/src/api/analytics.test.ts +++ b/packages/react/src/api/analytics.test.ts @@ -13,7 +13,7 @@ afterEach(() => { describe("analytics seam", () => { it("is a no-op when no client is mounted", () => { - expect(() => trackEvent("integration_connect_dialog_opened")).not.toThrow(); + expect(() => trackEvent("integration_browse_opened", { via: "header" })).not.toThrow(); }); it("forwards name and properties to the mounted client", () => { diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 915c87ffa..465e7b88c 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -31,12 +31,22 @@ type Owner = "org" | "user"; export interface AnalyticsEvents { // ── Integrations ───────────────────────────────────────────────────────── - integration_connect_dialog_opened: {}; + /** The full-page picker was opened. Replaces the connect dialog, whose + * `integration_connect_dialog_opened` this supersedes — keep both readable + * in dashboards spanning the change. */ + integration_browse_opened: { via: "header" | "empty-state" | "sidebar" }; integration_detect_submitted: { success: boolean; detected_kind?: string; confidence?: string; }; + /** Thumbs on the AI-generated credential guidance panel — the accuracy + * signal for the registry's machine-written setup text. */ + credential_guidance_rated: { + domain: string; + credential_label: string; + vote: "up" | "down"; + }; integration_add_started: { plugin_key: string; via: "detect" | "manual" | "preset" | "command_palette" | "catalog"; diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 7bc26aa7c..69e15bf8d 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -27,6 +27,7 @@ import { checkConnectionHealth, connectionsAllAtom, createOAuthClientOptimistic, + integrationAtom, integrationHealthCheckAtom, integrationHealthCheckCandidatesAtom, oauthClientsOptimisticAtom, @@ -85,6 +86,7 @@ import { } from "./oauth-client-form"; import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog"; import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal"; +import { CredentialGuidancePanel } from "./credential-guidance"; import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; import { connectionIdentifier } from "../lib/connection-name"; import { Badge } from "./badge"; @@ -625,6 +627,14 @@ export const connectionExistsMessage = (label: string): string => * explicit choice. Personal: a connection is most often a personal credential. */ export const DEFAULT_CONNECTION_OWNER: Owner = "user"; +/** The method the modal opens on. OAuth needs a registered app (or a DCR + * round-trip) before "Connect" does anything; a key is one paste. When an + * integration declares both, starting on OAuth greets most users with + * "Register app" — a dead end — while the working method sits one tab over. + * Prefer the first non-OAuth method; OAuth stays one click away. */ +export const preferredMethodId = (methods: readonly AuthMethod[]): string => + (methods.find((method) => method.kind !== "oauth") ?? methods[0])?.id ?? ""; + const authMethodKey = (method: AuthMethod): string => method.source === "custom" ? `custom:${String(method.template)}` : `declared:${method.id}`; @@ -1423,7 +1433,7 @@ function AddAccountModalView(props: AddAccountModalProps) { ); const [addingMethod, setAddingMethod] = useState(false); - const [methodId, setMethodId] = useState(methods[0]?.id ?? ""); + const [methodId, setMethodId] = useState(preferredMethodId(methods)); // One value per distinct credential input (`variable → pasted value`). A // single-secret method has just `{ token }`; a method with two distinct inputs // (e.g. Datadog's two keys) collects one value per variable. @@ -1501,6 +1511,12 @@ function AddAccountModalView(props: AddAccountModalProps) { // The integration's declared health check + its candidate operations. When a // check is configured we probe against it; when not, the user picks one of the // candidates inline to test the key (and we save it). + // The integration's display URL is how the registry's credential guidance is + // located — it names the provider this key belongs to. + const integrationRecord = useAtomValue(integrationAtom(integration)); + const integrationDisplayUrl = AsyncResult.isSuccess(integrationRecord) + ? integrationRecord.value?.displayUrl + : undefined; const healthCheckResult = useAtomValue(integrationHealthCheckAtom(integration)); const configuredHealthCheck = AsyncResult.isSuccess(healthCheckResult) ? healthCheckResult.value @@ -1605,7 +1621,7 @@ function AddAccountModalView(props: AddAccountModalProps) { m.id === initialState.template || String(m.template) === initialState.template, ) : undefined; - setMethodId(initialMethod?.id ?? allMethods[0]!.id); + setMethodId(initialMethod?.id ?? preferredMethodId(allMethods)); }, [allMethods, initialState?.template, methodId]); // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link. @@ -2894,6 +2910,15 @@ function AddAccountModalView(props: AddAccountModalProps) {
+ {/* What this key is called at the provider, the page + that mints it, and their own setup steps. The + question this dialog used to ask without + answering. */} + + {isOAuth && method ? ( cimdActive ? (
diff --git a/packages/react/src/components/auth-method-list-editor.tsx b/packages/react/src/components/auth-method-list-editor.tsx index 45b1344e1..c4ae40dd1 100644 --- a/packages/react/src/components/auth-method-list-editor.tsx +++ b/packages/react/src/components/auth-method-list-editor.tsx @@ -142,10 +142,11 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) {
{detected ? : null} - - Method {index + 1} - {row.seedLabel ? ` · ${row.seedLabel}` : ""} - + {/* Named by what it is. "Method 1" told the reader nothing + except that there might be a Method 2, and next to a + masked value it read as a locked credential field — + people tried to paste their key into it. */} + {detected ? detectedMethodTitle(row) : `Method ${index + 1}`} + ); + })} + + ); +} diff --git a/packages/react/src/components/integration-favicon.tsx b/packages/react/src/components/integration-favicon.tsx index a8acfa71d..441259953 100644 --- a/packages/react/src/components/integration-favicon.tsx +++ b/packages/react/src/components/integration-favicon.tsx @@ -209,7 +209,6 @@ export function IntegrationFavicon({ alt="" width={size} height={size} - loading="lazy" onError={() => setFailedSrcs((current) => current.includes(failedCandidate) ? current : [...current, failedCandidate], diff --git a/packages/react/src/console-routes.ts b/packages/react/src/console-routes.ts index 4d32b876e..def5841e5 100644 --- a/packages/react/src/console-routes.ts +++ b/packages/react/src/console-routes.ts @@ -37,6 +37,7 @@ export const CONSOLE_ROUTE_PATHS = [ "/connect/$integrationSlug", "/integrations/$namespace", "/integrations/add/$pluginKey", + "/integrations/browse", "/policies", "/secrets", "/tools", @@ -78,6 +79,7 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array { it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => { @@ -101,3 +101,25 @@ describe("authMethodsFromDescriptors", () => { expect(authMethodsFromDescriptors([])).toEqual([]); }); }); + +describe("placementFromHeaderPattern", () => { + it("keeps a Bearer prefix, space included", () => { + expect(placementFromHeaderPattern("Authorization: Bearer {token}")).toEqual({ + carrier: "header", + name: "Authorization", + prefix: "Bearer ", + }); + }); + + it("an empty prefix is meaningful: Linear's keys take no Bearer", () => { + expect(placementFromHeaderPattern("Authorization: {api_key}")).toEqual({ + carrier: "header", + name: "Authorization", + prefix: "", + }); + }); + + it("rejects text that is not a header pattern", () => { + expect(placementFromHeaderPattern("just some prose")).toBeNull(); + }); +}); diff --git a/packages/react/src/lib/auth-placements.tsx b/packages/react/src/lib/auth-placements.tsx index e61c307af..9b9311850 100644 --- a/packages/react/src/lib/auth-placements.tsx +++ b/packages/react/src/lib/auth-placements.tsx @@ -40,6 +40,22 @@ export interface Placement { /** A fresh, empty header placement — the default first row in an editor. */ export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", prefix: "" }); +/** Parses a registry header pattern — `"Authorization: Bearer {token}"` — into + * a placement. The literal text between the colon and the `{variable}` is the + * prefix, and its ABSENCE is load-bearing: `"Authorization: {api_key}"` is how + * the registry says Linear's personal keys take no Bearer prefix. */ +export function placementFromHeaderPattern(pattern: string): Placement | null { + const match = /^([A-Za-z0-9-]+):\s*(.*)$/.exec(pattern.trim()); + if (!match) return null; + const rest = match[2] ?? ""; + const brace = rest.indexOf("{"); + return { + carrier: "header", + name: match[1] ?? "", + prefix: brace >= 0 ? rest.slice(0, brace) : "", + }; +} + /** What an auth method is, presentationally. `kind` drives the credential UI: * `oauth` shows a Connect button, `none` creates a connection with no * credential inputs, and apikey/custom fill secrets across `placements`. diff --git a/packages/react/src/lib/integrations-sh-catalog.ts b/packages/react/src/lib/integrations-sh-catalog.ts index 24c84822a..c483d5638 100644 --- a/packages/react/src/lib/integrations-sh-catalog.ts +++ b/packages/react/src/lib/integrations-sh-catalog.ts @@ -9,7 +9,7 @@ // GraphQL endpoint). // --------------------------------------------------------------------------- -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -28,10 +28,49 @@ export type CatalogKind = (typeof CONNECTABLE_KINDS)[number]; const isConnectableKind = (kind: string): kind is CatalogKind => (CONNECTABLE_KINDS as readonly string[]).includes(kind); +/** One connectable surface of a domain, as the registry reports it. + * + * `slug` is the registry's stable identifier for THIS surface, and it is the + * namespace the add flow seeds — which makes it the honest answer to "have I + * added this already?". The domain is not: a vendor's surfaces can live on + * other hosts entirely (GitHub's MCP server is on api.githubcopilot.com), and + * one domain routinely offers several surfaces that are separate + * integrations. */ +export interface CatalogSurface { + readonly kind: CatalogKind; + readonly slug: string; + /** What to point the add flow at. Absent when the registry has no + * machine-readable locator, in which case the surface document still has to + * be fetched on click. */ + readonly url?: string; + /** A hand-picked product mark (Google Calendar's own logo rather than the + * generic G), present only on curated surfaces. */ + readonly icon?: string; + /** How to authenticate, for surfaces whose connect target cannot describe + * it itself — a GraphQL endpoint has no spec document, so the registry is + * the only carrier of facts like Linear's no-Bearer-prefix header. */ + readonly auth?: { + readonly kind?: string; + /** Header pattern, e.g. "Authorization: Bearer {token}". */ + readonly header?: string; + readonly note?: string; + }; + /** RFC 6902 JSON Patch the registry says to apply to the fetched spec — + * how a vendor's published document gets improved without hosting a fork + * (e.g. Neon's console session cookies posing as security schemes). */ + readonly specOverrides?: readonly unknown[]; +} + export interface CatalogSearchEntry { readonly domain: string; + /** The product's display name, when the row is a named product rather than + * a bare domain — "Outlook Mail" on graph.microsoft.com. */ + readonly name?: string; readonly description: string; readonly kinds: readonly CatalogKind[]; + /** Present on registries new enough to report it; absent responses fall back + * to resolving the surface document per click. */ + readonly surfaces?: readonly CatalogSurface[]; } export const catalogLogoUrl = (domain: string, size: number): string => @@ -44,7 +83,14 @@ class CatalogRequestError extends Data.TaggedError("CatalogRequestError")<{ const fetchCatalogJson = (url: URL): Effect.Effect => Effect.gen(function* () { const response = yield* Effect.tryPromise({ - try: () => fetch(url), + // ALWAYS REVALIDATE. The catalog is a live service and its corrections + // must reach the console immediately, but the browser will honour + // whatever max-age the response carried when it was stored — a stale + // entry with a long TTL kept showing a domain that had been merged away + // and an MCP endpoint that does not exist, hours after the fix shipped. + // `no-cache` still sends the conditional request, so a 304 costs nothing + // when nothing has changed. + try: () => fetch(url, { cache: "no-cache" }), catch: () => new CatalogRequestError({ message: `Failed to reach ${url.host}.` }), }); if (!response.ok) { @@ -66,34 +112,103 @@ const SearchResponse = Schema.Struct({ results: Schema.Array( Schema.Struct({ domain: Schema.String, + name: Schema.optional(Schema.String), description: Schema.String, kinds: Schema.Array(Schema.String), + surfaces: Schema.optional( + Schema.Array( + Schema.Struct({ + kind: Schema.String, + slug: Schema.String, + url: Schema.optional(Schema.String), + icon: Schema.optional(Schema.String), + auth: Schema.optional( + Schema.Struct({ + kind: Schema.optional(Schema.String), + header: Schema.optional(Schema.String), + note: Schema.optional(Schema.String), + }), + ), + specOverrides: Schema.optional(Schema.Array(Schema.Unknown)), + }), + ), + ), }), ), }); const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse); +export interface CatalogSearchPage { + readonly entries: readonly CatalogSearchEntry[]; + /** Rows the registry returned BEFORE the connectable-kind filter. Paging + * exhaustion must key on this: a page can be full at the registry and + * shrink here (CLI-only rows are dropped), and judging "no more pages" by + * the filtered length ended the infinite scroll mid-catalog. */ + readonly rawCount: number; +} + +export const parseCatalogSearchPage = (payload: unknown): CatalogSearchPage => ({ + entries: parseCatalogSearch(payload), + rawCount: Option.match(decodeSearchResponse(payload), { + onNone: () => 0, + onSome: ({ results }) => results.length, + }), +}); + export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntry[] => Option.match(decodeSearchResponse(payload), { onNone: () => [], onSome: ({ results }) => results - .map((entry) => ({ - domain: entry.domain, - description: entry.description, - kinds: entry.kinds.filter(isConnectableKind), - })) + .map((entry) => { + const surfaces = (entry.surfaces ?? []).flatMap((surface): readonly CatalogSurface[] => + isConnectableKind(surface.kind) + ? [ + { + kind: surface.kind, + slug: surface.slug, + ...(surface.url ? { url: surface.url } : {}), + ...(surface.icon ? { icon: surface.icon } : {}), + ...(surface.auth ? { auth: surface.auth } : {}), + ...(surface.specOverrides && surface.specOverrides.length > 0 + ? { specOverrides: surface.specOverrides } + : {}), + }, + ] + : [], + ); + return { + domain: entry.domain, + ...(entry.name && entry.name !== entry.domain ? { name: entry.name } : {}), + description: entry.description, + kinds: entry.kinds.filter(isConnectableKind), + ...(surfaces.length > 0 ? { surfaces } : {}), + }; + }) .filter((entry) => entry.kinds.length > 0), }); +export interface CatalogQuery { + /** Free text. EMPTY IS MEANINGFUL: the registry answers an empty query with + * its popularity-ordered head, which is what a browse view wants. */ + readonly query: string; + /** Restrict to one surface kind. Omitted means every connectable kind. */ + readonly kind?: CatalogKind; + /** The registry caps this at 100. */ + readonly limit?: number; + /** Ranked results to skip — how the picker pages an endless list. */ + readonly offset?: number; +} + export const searchCatalog = ( - query: string, - limit = 10, -): Effect.Effect => { + input: CatalogQuery, +): Effect.Effect => { const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN); - url.searchParams.set("q", query); - url.searchParams.set("limit", String(limit)); - return Effect.map(fetchCatalogJson(url), parseCatalogSearch); + url.searchParams.set("q", input.query); + url.searchParams.set("limit", String(input.limit ?? 10)); + if (input.kind) url.searchParams.set("kind", input.kind); + if (input.offset) url.searchParams.set("offset", String(input.offset)); + return Effect.map(fetchCatalogJson(url), parseCatalogSearchPage); }; // --------------------------------------------------------------------------- @@ -135,18 +250,35 @@ const connectUrlOf = (surface: SurfaceRecord, kind: CatalogKind): string | undef ? surface.url : undefined; +const normalizedProductToken = (value: string): string => + value.toLowerCase().replace(/[^a-z0-9]/g, ""); + export const pickConnectTarget = ( payload: unknown, kind: CatalogKind, + preferredName?: string, ): CatalogConnectTarget | undefined => Option.match(decodeSurfaceDocument(payload), { onNone: () => undefined, onSome: ({ surfaces }) => { - for (const surface of surfaces) { + // One domain can carry several same-kind products (Google Photos + // Library and Picker share photos.google.com). When the caller names + // the product, a slug whose normalized form matches wins; first-of-kind + // is only the answer when nothing identifies the product. + const usable = surfaces.flatMap((surface) => { const url = connectUrlOf(surface, kind); - if (url) return { kind, url, ...(surface.slug ? { slug: surface.slug } : {}) }; + return url + ? [{ kind, url, ...(surface.slug ? { slug: surface.slug } : {}) } as CatalogConnectTarget] + : []; + }); + if (preferredName) { + const token = normalizedProductToken(preferredName); + const named = usable.find( + (target) => target.slug && normalizedProductToken(target.slug) === token, + ); + if (named) return named; } - return undefined; + return usable[0]; }, }); @@ -155,11 +287,12 @@ export const pickConnectTarget = ( export const resolveConnectTarget = ( domain: string, kinds: readonly CatalogKind[], + preferredName?: string, ): Effect.Effect => { const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN); return Effect.map(fetchCatalogJson(url), (payload) => { for (const kind of kinds) { - const target = pickConnectTarget(payload, kind); + const target = pickConnectTarget(payload, kind, preferredName); if (target) return target; } return undefined; @@ -172,16 +305,27 @@ export const resolveConnectTarget = ( /** Domains already represented by a loaded plugin's presets, so the catalog * section doesn't repeat what the preset list above it already shows. */ +/** The domain a preset represents, read from its integrations.sh logo URL + * first (which names the domain outright) and its spec/endpoint URL second. */ +export const presetDomain = (preset: { + readonly icon?: string; + readonly url?: string; +}): string | null => { + for (const candidate of [preset.icon, preset.url]) { + if (!candidate) continue; + const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate); + const domain = logoMatch?.[1] ?? getDomain(candidate); + if (domain) return domain; + } + return null; +}; + export const presetDomains = (plugins: readonly IntegrationPlugin[]): ReadonlySet => { const domains = new Set(); for (const plugin of plugins) { for (const preset of plugin.presets ?? []) { - for (const candidate of [preset.icon, preset.url]) { - if (!candidate) continue; - const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate); - const domain = logoMatch?.[1] ?? getDomain(candidate); - if (domain) domains.add(domain); - } + const domain = presetDomain(preset); + if (domain) domains.add(domain); } } return domains; @@ -206,6 +350,9 @@ export const filterCatalogEntries = ( .map((entry) => ({ ...entry, kinds: entry.kinds.filter((kind) => opts.availableKinds.includes(kind)), + ...(entry.surfaces + ? { surfaces: entry.surfaces.filter((s) => opts.availableKinds.includes(s.kind)) } + : {}), })) .filter((entry) => entry.kinds.length > 0); @@ -213,43 +360,293 @@ export const filterCatalogEntries = ( // Hook — debounced search with an in-session response cache // --------------------------------------------------------------------------- -const SEARCH_DEBOUNCE_MS = 250; +const SEARCH_DEBOUNCE_MS = 150; +/** Free-text queries wait for a second character; a browse (empty query) does + * not, because there is nothing to narrow. */ const MIN_QUERY_LENGTH = 2; -const searchCache = new Map(); +const BROWSE_LIMIT = 60; export interface CatalogSearchState { readonly entries: readonly CatalogSearchEntry[]; readonly loading: boolean; + /** The entries belong to a previous query, held over while the current one + * loads. Callers must not render them as if they answered the live query. */ + readonly stale: boolean; + /** The registry could not be reached. The rest of the page still works, so + * this is a section-level notice rather than a page error. */ + readonly failed: boolean; + /** The last page came back full, so more rows likely exist. */ + readonly hasMore: boolean; + /** A further page is being fetched and appended. */ + readonly loadingMore: boolean; + /** Fetch the next page. Safe to call repeatedly: no-op while a page is in + * flight or once the catalog is exhausted. */ + readonly loadMore: () => void; } -export function useCatalogSearch(rawQuery: string): CatalogSearchState { - const query = rawQuery.trim().toLowerCase(); - const [state, setState] = useState({ entries: [], loading: false }); +const cacheKey = (input: CatalogQuery): string => + `${input.kind ?? "all"}:${input.limit ?? 0}:${input.query}`; +/** Responses are cached for as long as the registry says they are fresh, and + * no longer. The first version of this cache had no expiry at all, so a + * session that searched once kept that answer for its whole life — a registry + * correction could ship and the console would keep showing the old catalog + * until the tab was closed. */ +const SEARCH_TTL_MS = 60_000; +const searchCache = new Map< + string, + { readonly at: number; readonly entries: readonly CatalogSearchEntry[] } +>(); + +/** + * Browse or search the public registry. + * + * One hook for both because they are the same request with a different `q`: + * an empty query returns the popularity-ordered head, a non-empty one filters + * it. Search stays server-side and edge-cached, so no view ever downloads the + * multi-thousand-entry catalog. + */ +export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState { + const query = input.query.trim().toLowerCase(); + const kind = input.kind; + const limit = input.limit ?? BROWSE_LIMIT; + const requestKeyOf = (forQuery: string) => + cacheKey({ query: forQuery, limit, ...(kind ? { kind } : {}) }); + const requestKey = requestKeyOf(query); + const [state, setState] = useState<{ + readonly entries: readonly CatalogSearchEntry[]; + /** The request the entries belong to. Held-over results from a previous + * query are DELIBERATE (no empty flash between keystrokes), but they + * must be knowable as stale — rendering them unmarked is how a + * "calendar" search showed Gmail cards. */ + readonly forKey: string; + readonly loading: boolean; + readonly failed: boolean; + }>({ entries: [], forKey: requestKey, loading: true, failed: false }); const generation = useRef(0); + // Pages beyond the first, keyed to the request they extend so a keystroke + // discards them with the first page rather than leaking into new results. + const [more, setMore] = useState<{ + readonly key: string; + readonly entries: readonly CatalogSearchEntry[]; + /** RAW rows consumed by extra pages — the server pages by raw index, and + * the connectable-kind filter makes filtered counts useless as offsets. */ + readonly rawConsumed: number; + readonly loadingMore: boolean; + readonly exhausted: boolean; + }>({ key: requestKey, entries: [], rawConsumed: 0, loadingMore: false, exhausted: false }); + + useEffect(() => { + setMore({ key: requestKey, entries: [], rawConsumed: 0, loadingMore: false, exhausted: false }); + // requestKey is derived from (query, kind, limit) — same trigger set. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, kind, limit]); useEffect(() => { const requestId = ++generation.current; - if (query.length < MIN_QUERY_LENGTH) { - setState({ entries: [], loading: false }); + // A partial word is not yet a query; leave the previous results in place + // rather than flashing an empty list between keystrokes. + if (query.length > 0 && query.length < MIN_QUERY_LENGTH) { + // Held entries keep their own forKey, so the hook reports them stale. + setState((previous) => ({ ...previous, loading: false })); return; } - const cached = searchCache.get(query); - if (cached) { - setState({ entries: cached, loading: false }); + const request: CatalogQuery = { query, limit, ...(kind ? { kind } : {}) }; + const cached = searchCache.get(cacheKey(request)); + if (cached && Date.now() - cached.at < SEARCH_TTL_MS) { + setState({ + entries: cached.entries, + forKey: cacheKey(request), + loading: false, + failed: false, + }); return; } setState((previous) => ({ ...previous, loading: true })); - const timer = setTimeout(() => { - void Effect.runPromiseExit(searchCatalog(query)).then((exit) => { - if (Exit.isSuccess(exit)) searchCache.set(query, exit.value); - if (generation.current !== requestId) return; - // Reachability is best-effort — the presets and URL detection above - // the catalog section keep working without it. - setState({ entries: Exit.isSuccess(exit) ? exit.value : [], loading: false }); - }); - }, SEARCH_DEBOUNCE_MS); + const timer = setTimeout( + () => { + void Effect.runPromiseExit(searchCatalog(request)).then((exit) => { + if (Exit.isSuccess(exit)) { + searchCache.set(cacheKey(request), { at: Date.now(), entries: exit.value.entries }); + } + if (generation.current !== requestId) return; + setState({ + entries: Exit.isSuccess(exit) ? exit.value.entries : [], + forKey: cacheKey(request), + loading: false, + failed: Exit.isFailure(exit), + }); + }); + }, + // The first browse should not sit behind a debounce meant for typing. + query.length === 0 ? 0 : SEARCH_DEBOUNCE_MS, + ); return () => clearTimeout(timer); - }, [query]); + }, [query, kind, limit]); + + const extras = more.key === requestKey ? more : null; + const baseCount = state.entries.length; + const hasMore = + !state.loading && + !state.failed && + baseCount >= limit && + !(extras?.exhausted ?? false) && + !(query.length > 0 && query.length < MIN_QUERY_LENGTH); + + // SYNCHRONOUS in-flight guard. A fast scroll fires the sentinel several + // times before React commits `loadingMore`, and every call then saw the + // stale false, fetched the SAME offset in parallel, appended the duplicate + // page, and pushed the next offset past rows that were never shown. A ref + // is settled the instant the first call claims it. + const pageInFlight = useRef(false); + + const loadMore = useCallback(() => { + if (!hasMore || pageInFlight.current) return; + pageInFlight.current = true; + setMore((previous) => + previous.key === requestKey + ? { ...previous, loadingMore: true } + : { key: requestKey, entries: [], rawConsumed: 0, loadingMore: true, exhausted: false }, + ); + // The first page consumed `limit` raw rows (were it short, hasMore would + // already be false); extra pages report their raw size. + const request: CatalogQuery = { + query, + limit, + offset: limit + (extras?.rawConsumed ?? 0), + ...(kind ? { kind } : {}), + }; + void Effect.runPromiseExit(searchCatalog(request)).then((exit) => { + pageInFlight.current = false; + setMore((previous) => { + if (previous.key !== requestKey) return previous; + const page = Exit.isSuccess(exit) ? exit.value : { entries: [], rawCount: 0 }; + return { + key: requestKey, + entries: [...previous.entries, ...page.entries], + rawConsumed: previous.rawConsumed + page.rawCount, + loadingMore: false, + // Exhaustion keys on the RAW page size: the connectable-kind filter + // shrinks pages, and a failed fetch is not the end of the catalog — + // the sentinel simply tries again next time it scrolls into view. + exhausted: Exit.isSuccess(exit) && page.rawCount < limit, + }; + }); + }); + }, [hasMore, requestKey, query, kind, limit, extras?.rawConsumed]); + + const entries = useMemo(() => { + if (!extras || extras.entries.length === 0) return state.entries; + // Ranking can shift between page fetches (the live index moves); a row + // that slid across a page boundary must not render twice. + const seen = new Set(state.entries.map((entry) => `${entry.domain}|${entry.name ?? ""}`)); + return [ + ...state.entries, + ...extras.entries.filter((entry) => { + const key = `${entry.domain}|${entry.name ?? ""}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }), + ]; + }, [state.entries, extras]); + + return { + entries, + loading: state.loading, + failed: state.failed, + /** The rendered entries belong to a PREVIOUS query (held over while the + * current one loads, or while the input is one character). Callers + * decide the treatment; unmarked stale results read as wrong results. */ + stale: state.forKey !== requestKey, + hasMore: hasMore && state.forKey === requestKey, + loadingMore: extras?.loadingMore ?? false, + loadMore, + }; +} + +// --------------------------------------------------------------------------- +// Credential guidance +// +// The per-domain surface document records, for each way of authenticating, +// what the credential is called at the provider, the page that mints it, and +// the provider's own setup steps. The console fetched this document for its +// connect URL and discarded the rest — which is why the add-connection modal +// could not answer "what kind of key do I need?" or "where do I get one?" +// while asking for exactly that. +// --------------------------------------------------------------------------- + +export interface CredentialGuidance { + readonly id: string; + readonly type: string; + /** What the provider calls this credential ("Personal API key"). */ + readonly label: string; + /** The page that mints it. */ + readonly generateUrl?: string; + /** The provider's own instructions, as markdown. */ + readonly setup?: string; +} + +const CredentialsDocument = Schema.Struct({ + credentials: Schema.optional( + Schema.Record( + Schema.String, + Schema.Struct({ + type: Schema.optional(Schema.String), + label: Schema.optional(Schema.String), + generateUrl: Schema.optional(Schema.String), + setup: Schema.optional(Schema.String), + }), + ), + ), +}); +const decodeCredentials = Schema.decodeUnknownOption(CredentialsDocument); + +export const fetchCredentialGuidance = ( + domain: string, +): Effect.Effect => { + const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN); + return Effect.map(fetchCatalogJson(url), (payload) => + Option.match(decodeCredentials(payload), { + onNone: () => [], + onSome: ({ credentials }) => + Object.entries(credentials ?? {}).map(([id, value]) => ({ + id, + type: value.type ?? "unknown", + label: value.label ?? id, + ...(value.generateUrl ? { generateUrl: value.generateUrl } : {}), + ...(value.setup ? { setup: value.setup } : {}), + })), + }), + ); +}; + +const guidanceCache = new Map(); + +/** Guidance for a domain, or an empty list when the registry has none. Never + * fails loudly: this is help text, and a connection form must work without it. */ +export function useCredentialGuidance(domain: string | null): readonly CredentialGuidance[] { + const [state, setState] = useState([]); + + useEffect(() => { + if (!domain) { + setState([]); + return; + } + const cached = guidanceCache.get(domain); + if (cached) { + setState(cached); + return; + } + let live = true; + void Effect.runPromiseExit(fetchCredentialGuidance(domain)).then((exit) => { + const value = Exit.isSuccess(exit) ? exit.value : []; + guidanceCache.set(domain, value); + if (live) setState(value); + }); + return () => { + live = false; + }; + }, [domain]); return state; } diff --git a/packages/react/src/multiplayer/shell.tsx b/packages/react/src/multiplayer/shell.tsx index 795e16716..1bd109ece 100644 --- a/packages/react/src/multiplayer/shell.tsx +++ b/packages/react/src/multiplayer/shell.tsx @@ -1,4 +1,4 @@ -import { Link, Outlet, useLocation, useParams } from "@tanstack/react-router"; +import { Link, Outlet, useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; @@ -23,7 +23,6 @@ import { } from "../components/integration-favicon"; import { CommandPalette } from "../components/command-palette"; import { Wordmark } from "../components/wordmark"; -import { ConnectDialog } from "../pages/integrations"; import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; import { useAuth } from "./auth-context"; @@ -428,7 +427,13 @@ export function Shell(props: ShellProps) { const lastPathname = useRef(pathname); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); - const [connectIntegrationOpen, setConnectIntegrationOpen] = useState(false); + const navigate = useNavigate(); + // The connect dialog became the full-page picker; the sidebar affordance + // navigates instead of opening a modal. + const openIntegrationBrowse = () => { + trackEvent("integration_browse_opened", { via: "sidebar" }); + void navigate({ to: "/{-$orgSlug}/integrations/browse" }); + }; if (lastPathname.current !== pathname) { lastPathname.current = pathname; if (mobileSidebarOpen) setMobileSidebarOpen(false); @@ -446,17 +451,13 @@ export function Shell(props: ShellProps) { return (
- {/* Desktop sidebar */} @@ -502,8 +503,7 @@ export function Shell(props: ShellProps) { }} onOpenIntegrationConnect={() => { setMobileSidebarOpen(false); - setConnectIntegrationOpen(true); - trackEvent("integration_connect_dialog_opened"); + openIntegrationBrowse(); }} />
diff --git a/packages/react/src/pages/integration-add.tsx b/packages/react/src/pages/integration-add.tsx index f758a3c49..9691ca4cd 100644 --- a/packages/react/src/pages/integration-add.tsx +++ b/packages/react/src/pages/integration-add.tsx @@ -15,9 +15,14 @@ export function AddIntegrationPage(props: { url?: string; preset?: string; namespace?: string; + authHeader?: string; + authNote?: string; + authKind?: string; + specOverrides?: string; }) { useExecutorDocumentTitle("Add integration"); - const { pluginKey, url, preset, namespace } = props; + const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } = + props; const navigate = useNavigate(); const integrationPlugins = useIntegrationPlugins(); const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); @@ -57,6 +62,10 @@ export function AddIntegrationPage(props: { initialUrl={url} initialPreset={preset} initialNamespace={namespace} + initialAuthHeader={authHeader} + initialAuthNote={authNote} + initialAuthKind={authKind} + initialSpecOverrides={specOverrides} onComplete={(slug?: string) => { trackEvent("integration_added", { plugin_key: pluginKey, diff --git a/packages/react/src/pages/integration-browse.tsx b/packages/react/src/pages/integration-browse.tsx new file mode 100644 index 000000000..83b133e28 --- /dev/null +++ b/packages/react/src/pages/integration-browse.tsx @@ -0,0 +1,1041 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import { PlusIcon, SearchIcon } from "lucide-react"; +import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared"; +import { + useIntegrationPlugins, + type IntegrationPlugin, + type IntegrationPreset, + type IntegrationQuickAddInput, + type IntegrationQuickAddResult, +} from "@executor-js/sdk/client"; + +import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms"; +import { slugifyNamespace } from "../plugins/namespace"; +import { trackEvent } from "../api/analytics"; +import { Button } from "../components/button"; +import { Input } from "../components/input"; +import { PageHeader } from "../components/page"; +import { + integrationFaviconUrl, + integrationInferredUrl, + integrationPresetIconUrl, +} from "../components/integration-favicon"; +import { Skeleton } from "../components/skeleton"; +import { useExecutorDocumentTitle } from "../lib/document-title"; +import { + availableCatalogKinds, + catalogLogoUrl, + filterCatalogEntries, + resolveConnectTarget, + useCatalogBrowse, + type CatalogKind, + type CatalogSearchEntry, + type CatalogSurface, +} from "../lib/integrations-sh-catalog"; + +// --------------------------------------------------------------------------- +// The full-page integration picker. +// +// Replaces the connect dialog. Finding something to connect is the first task +// of onboarding, so it gets a page and a permanent, focused search field rather +// than a 16rem scroll window behind a header button. +// +// ONE ROW PER ADDABLE THING. A service exposing both an API and an MCP server +// yields two rows, because they really are two different integrations with +// different tools and different auth — but each row NAMES its surface +// ("Stripe API", "Stripe MCP") rather than repeating the bare service name and +// leaving a badge to carry the difference. Two rows reading "Stripe" look like +// a duplicate; two rows reading "Stripe API" and "Stripe MCP" look like a +// choice, which is what they are. +// +// THE REGISTRY IS THE LIST. Every connectable card is a registry row: the +// registry carries the name, domain, description, spec or endpoint, auth +// facts, and corrective spec overrides, and the spec itself declares how to +// authenticate — a deployment's first-party OAuth clients bind at connect +// time by endpoint host, not through the picker. The one exception is +// presets WITHOUT a connect URL (local-process servers like Chrome DevTools +// over stdio): those cannot be registry rows yet, so they remain cards. +// +// FACETS ARE SURFACE KIND, NOT CATEGORY. There is no taxonomy to facet on, so a +// category rail would have to invent one. Kind is real, filtered server-side. +// --------------------------------------------------------------------------- + +const KIND_TO_PLUGIN_KEY: Record = { + openapi: "openapi", + mcp: "mcp", + graphql: "graphql", + googleDiscovery: "google", +}; + +const CATALOG_KIND_LABEL: Record = { + mcp: "MCP", + openapi: "OpenAPI", + graphql: "GraphQL", +}; + +/** How a surface is said in a row's NAME. Deliberately not the facet + * vocabulary: a facet filters on the spec format ("OpenAPI"), while a name + * says the thing you would say out loud ("Stripe API"). */ +const SURFACE_WORD: Record = { + mcp: "MCP", + openapi: "API", + google: "API", + graphql: "GraphQL", +}; + +/** `Stripe` + API → `Stripe API`, but `GitHub REST` + API stays `GitHub REST` + * and `Emulate MCP` + MCP stays `Emulate MCP`. Appending a word the name + * already carries reads worse than leaving it off. */ +const withSurface = (name: string, surface: string): string => { + const lower = name.toLowerCase(); + const already = + lower.includes(surface.toLowerCase()) || + (surface === "API" && (lower.includes("api") || lower.includes("rest"))); + return already ? name : `${name} ${surface}`; +}; + +/** The key on which a preset and a catalog row count as the same product: + * the name with surface words and punctuation stripped, plus the kind. + * "GitHub REST" and "GitHub API" are one product; "Stripe API" and + * "Stripe MCP" are not. */ +const productKey = (name: string, kind: string): string => + `${name + .toLowerCase() + .replace(/\b(api|rest|graphql|mcp)\b/g, "") + .replace(/[^a-z0-9]/g, "")}|${kind}`; + +const detectionRank: Record = { + high: 3, + medium: 2, + low: 1, +}; + +const bestDetection = ( + results: readonly IntegrationDetectionResult[], +): IntegrationDetectionResult | undefined => + [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; + +/** The input either names a thing to look for or points at one. Anything with + * a scheme, a slash, or a host-with-TLD is a URL; everything else is a query. */ +const looksLikeUrl = (raw: string): boolean => { + const value = raw.trim(); + if (value.length === 0) return false; + if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(value)) return true; + if (value.includes("/")) return true; + if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(value)) return true; + return false; +}; + +/** A readable product name for a bare domain: `gmail.googleapis.com` reads as + * "Gmail", `linear.app` as "Linear". The full domain still renders beside it, + * because two services can share a leading label and the domain is what + * actually disambiguates them. */ +const domainDisplayName = (domain: string): string => { + const host = domain.replace(/^www\./, ""); + const label = host.split(".")[0] ?? host; + return label.charAt(0).toUpperCase() + label.slice(1); +}; + +/** The registry truncates descriptions to a fixed width, which lands mid-word + * ("…read, manage, and send m"). Trim back to the last whole word so the cut + * reads as deliberate. */ +const tidyDescription = (text: string): string => { + const trimmed = text.trim(); + if (trimmed.length === 0) return trimmed; + if (/[.!?]$/.test(trimmed)) return trimmed; + const lastSpace = trimmed.lastIndexOf(" "); + return `${(lastSpace > 40 ? trimmed.slice(0, lastSpace) : trimmed).replace(/[,;:]$/, "")}…`; +}; + +type PresetEntry = { + readonly preset: IntegrationPreset; + readonly pluginKey: string; + readonly pluginLabel: string; +}; + +interface Row { + readonly key: string; + readonly testId: string; + readonly title: string; + /** Surface kind for product matching (productKey); not rendered. */ + readonly kindKey: string; + /** The domain, shown beside a prettified title so near-namesakes stay + * distinguishable. Omitted when the title is already the domain. */ + readonly domain?: string; + readonly description?: string; + readonly iconUrl?: string; + readonly onSelect: () => void; + readonly added: boolean; + /** Namespace of the installed integration this row matched — the View + * destination. Known for quick-added rows and for rows whose installed + * match came from the catalog list. */ + readonly viewSlug?: string; + /** Added DURING this picker session (quick add). Excluded from the + * added-first float: the card the user just clicked must keep its place — + * teleporting it to the top mid-interaction is exactly the jumping this + * page keeps having to unlearn. It floats on the next visit. */ + readonly freshlyAdded?: boolean; + readonly busy: boolean; + /** A click-time failure, rendered on this card rather than page-top. */ + readonly error?: string; +} + +// --------------------------------------------------------------------------- +// Infinite scroll +// --------------------------------------------------------------------------- + +/** Fires `onVisible` whenever the sentinel scrolls near the viewport. The + * rootMargin starts the next page a screen early, so scrolling never visibly + * hits the bottom of a still-growing list. */ +function LoadMoreSentinel(props: { readonly onVisible: () => void }) { + const ref = useRef(null); + const { onVisible } = props; + useEffect(() => { + const node = ref.current; + if (!node) return; + const observer = new IntersectionObserver( + (observed) => { + if (observed.some((entry) => entry.isIntersecting)) onVisible(); + }, + { rootMargin: "600px" }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [onVisible]); + return
; +} + +// --------------------------------------------------------------------------- +// Row +// --------------------------------------------------------------------------- + +/** Registry logos 404 for plenty of hosts; a broken-image glyph in a list of + * brand marks looks like a bug, so failures fall back to a neutral mark. */ +function RowIcon(props: { readonly src?: string; readonly alt: string }) { + const [failed, setFailed] = useState(false); + if (!props.src || failed) { + return ( + + {props.alt.charAt(0).toUpperCase()} + + ); + } + return ( + setFailed(true)} + className="size-5 object-contain" + /> + ); +} + +function ResultCard(props: { readonly row: Row }) { + const { row } = props; + return ( +
+
+ + + + {row.added ? ( + // The add happened HERE — the card is the receipt. View jumps to + // the integration's hub, where authenticating happens. + row.viewSlug ? ( + + ) : ( + Added + ) + ) : row.busy ? ( + Adding… + ) : ( + + )} +
+
+

{row.title}

+ {row.domain ? ( +

{row.domain}

+ ) : null} +
+ {row.description ? ( +

+ {row.description} +

+ ) : null} + {row.error ? ( +

+ {row.error} +

+ ) : null} +
+ ); +} + +function CardSkeleton(props: { readonly index: number }) { + const { index } = props; + return ( +
+
+ + +
+
+ + +
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// Quick add +// --------------------------------------------------------------------------- + +type QuickAddFn = (input: IntegrationQuickAddInput) => Promise; + +/** Collects each plugin's bound quick-add callback into the page's map. + * A component per plugin, not a hook over a list: `useQuickAdd` is a hook, + * and mounting one bridge per plugin is how a fixed set of per-plugin hooks + * stays rules-of-hooks clean. */ +function QuickAddBridge(props: { + readonly pluginKey: string; + /** The plugin's hook, called UNCONDITIONALLY — the host renders a bridge + * only for plugins that have one, so this component's hook sequence never + * depends on a plugin object's shape. */ + readonly useQuickAdd: () => QuickAddFn; + readonly register: (key: string, fn: QuickAddFn | null) => void; +}) { + const { pluginKey, useQuickAdd, register } = props; + const fn = useQuickAdd(); + useEffect(() => { + register(pluginKey, fn); + return () => register(pluginKey, null); + }, [pluginKey, fn, register]); + return null; +} + +/** One bridge per plugin KEY: a duplicate key would share the React key and + * the callback slot, letting either copy's cleanup delete the other. */ +function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { + const seen = new Set(); + return plugins.filter((plugin) => { + if (!plugin.useQuickAdd || seen.has(plugin.key)) return false; + seen.add(plugin.key); + return true; + }); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export function IntegrationBrowsePage() { + useExecutorDocumentTitle("Add an integration"); + const navigate = useNavigate(); + const integrationPlugins = useIntegrationPlugins(); + const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); + const installed = useAtomValue(integrationsOptimisticAtom); + + const [query, setQuery] = useState(""); + const [detecting, setDetecting] = useState(false); + const [error, setError] = useState(null); + // Resolve-on-click failures belong on the card that was clicked — a page-top + // alert for a card three screens down reads as a silent failure. + const [rowError, setRowError] = useState<{ + readonly key: string; + readonly message: string; + } | null>(null); + const [resolvingDomain, setResolvingDomain] = useState(null); + // One-click adds run per-card and CONCURRENTLY — adding Linear must not + // lock the Notion card. Keyed by row error-key (`domain|kind`). + const [quickAddingKeys, setQuickAddingKeys] = useState>(new Set()); + // Slugs of integrations added from this page, keyed the same way, so the + // card can flip to View without waiting for the catalog refetch. + const [quickAddedSlugs, setQuickAddedSlugs] = useState>(new Map()); + const quickAdders = useRef(new Map()); + const registerQuickAdd = useCallback((key: string, fn: QuickAddFn | null) => { + if (fn) quickAdders.current.set(key, fn); + else quickAdders.current.delete(key); + }, []); + + const isUrl = looksLikeUrl(query); + // A URL is a destination, not a filter — but half-typed URLs pass through + // here on every keystroke ("stripe.co" parses as one), and swapping the + // grid back to the browse head mid-typing made the whole page jump. Freeze + // the list on the last real query instead; the detect hint is what changes. + const lastTextQuery = useRef(""); + if (!isUrl) lastTextQuery.current = query; + const listQuery = isUrl ? lastTextQuery.current : query; + const text = listQuery.trim().toLowerCase(); + + const availableKinds = useMemo( + () => availableCatalogKinds(integrationPlugins), + [integrationPlugins], + ); + // Nothing to exclude now that the registry is the only source. + const excludeDomains = useMemo(() => new Set(), []); + + const catalog = useCatalogBrowse({ query: listQuery }); + const catalogEntries = useMemo(() => { + const usable = filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds }); + if (!catalog.stale) return usable; + // Held-over results from the previous query: keep only what still reads + // as an answer to the live text. Every whitespace token must appear in + // the entry's name or domain — refining "google"→"google cal" keeps + // cloud.google.com rows (the match lives in the domain), while a new + // word drops the old rows instead of parading them under the wrong + // query, which is how a calendar search showed Gmail. An empty text + // (clearing back to browse) holds nothing. + const tokens = listQuery.trim().toLowerCase().split(/\s+/).filter(Boolean); + if (tokens.length === 0) return []; + return usable.filter((entry) => { + const haystack = + `${entry.name ?? domainDisplayName(entry.domain)} ${entry.domain}`.toLowerCase(); + return tokens.every((token) => haystack.includes(token)); + }); + }, [catalog.entries, catalog.stale, listQuery, excludeDomains, availableKinds]); + + /** What is already connected, as `:`. + * + * The slug is the closest thing to an identity available today — it is the + * namespace the add flow seeds from a registry surface's `slug` or a + * preset's name. Kind is carried alongside it because slugs are NOT unique + * per surface: the OpenAPI and MCP presets for Stripe, Neon, Sentry and + * Axiom share a name and an id, so both seed the same namespace. Without the + * kind, adding one marks the other added — the same false positive the + * earlier domain match produced. + * + * This still under-reports: a connection the user renamed on the way in no + * longer matches, and reads as not-added. That is deliberate — offering to + * add something twice is recoverable, claiming something is connected when + * it is not is not. The real fix is recording at add time which row an + * integration came from; nothing here can infer it after the fact. */ + const installedKeys = useMemo(() => { + const rows: readonly Integration[] = AsyncResult.isSuccess(installed) ? installed.value : []; + return new Set( + rows.map((row) => `${String(row.slug)}:${KIND_TO_PLUGIN_KEY[row.kind] ?? row.kind}`), + ); + }, [installed]); + + const isAdded = useCallback( + (kind: string, ...candidates: readonly (string | undefined)[]): boolean => + candidates.some((candidate) => { + if (!candidate) return false; + return ( + installedKeys.has(`${candidate}:${kind}`) || + installedKeys.has(`${slugifyNamespace(candidate)}:${kind}`) + ); + }), + [installedKeys], + ); + + /** The installed integration's own namespace for an added row — the View + * button's destination. Same matching rules as `isAdded`. */ + const installedSlugFor = useCallback( + (kind: string, ...candidates: readonly (string | undefined)[]): string | undefined => { + for (const candidate of candidates) { + if (!candidate) continue; + if (installedKeys.has(`${candidate}:${kind}`)) return candidate; + const slugified = slugifyNamespace(candidate); + if (installedKeys.has(`${slugified}:${kind}`)) return slugified; + } + return undefined; + }, + [installedKeys], + ); + + const allPresets = useMemo(() => { + const entries: PresetEntry[] = []; + for (const plugin of integrationPlugins) { + for (const preset of plugin.presets ?? []) { + entries.push({ preset, pluginKey: plugin.key, pluginLabel: plugin.label }); + } + } + return entries; + }, [integrationPlugins]); + + const handleDetect = useCallback(async () => { + const trimmed = query.trim(); + if (trimmed.length === 0) return; + setDetecting(true); + setError(null); + // Detection is read-only — it inspects a URL and returns candidates without + // mutating the catalog, so it invalidates nothing. + const exit = await doDetect({ payload: { url: trimmed }, reactivityKeys: [] }); + if (Exit.isFailure(exit)) { + trackEvent("integration_detect_submitted", { success: false }); + setError("Couldn't reach that URL. Check it, or start from scratch below."); + setDetecting(false); + return; + } + const detected = bestDetection(exit.value); + if (!detected) { + trackEvent("integration_detect_submitted", { success: false }); + setError("Couldn't tell what that URL exposes. Start from scratch below."); + setDetecting(false); + return; + } + trackEvent("integration_detect_submitted", { + success: true, + detected_kind: detected.kind, + confidence: detected.confidence, + }); + const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind] ?? detected.kind; + if (!integrationPlugins.some((plugin) => plugin.key === pluginKey)) { + setError(`That looks like a ${detected.kind} integration, which this server can't add.`); + setDetecting(false); + return; + } + trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" }); + void navigate({ + to: "/{-$orgSlug}/integrations/add/$pluginKey", + params: { pluginKey }, + search: { url: trimmed, namespace: detected.slug }, + }); + }, [query, doDetect, navigate, integrationPlugins]); + + const goToAdd = useCallback( + (input: { + readonly kind: string; + readonly url: string; + readonly slug?: string; + readonly domain: string; + readonly auth?: CatalogSurface["auth"]; + readonly specOverrides?: CatalogSurface["specOverrides"]; + }) => { + trackEvent("integration_add_started", { + plugin_key: input.kind, + via: "catalog", + catalog_domain: input.domain, + }); + void navigate({ + to: "/{-$orgSlug}/integrations/add/$pluginKey", + params: { pluginKey: input.kind }, + search: { + url: input.url, + ...(input.slug ? { namespace: input.slug } : {}), + ...(input.auth?.header ? { authHeader: input.auth.header } : {}), + ...(input.auth?.note ? { authNote: input.auth.note } : {}), + ...(input.auth?.kind ? { authKind: input.auth.kind } : {}), + ...(input.specOverrides ? { specOverrides: JSON.stringify(input.specOverrides) } : {}), + }, + }); + }, + [navigate], + ); + + /** One-click add, in place. The registry knew the URL and the auth + * indicators, so there is nothing to configure — register directly and + * flip the card to View, leaving the user on this page to keep adding. + * Returns false when it could not (no quick-add for the kind, probe or + * registration failed), and the caller falls back to the configuration + * screen, which renders the same failure with full context. */ + const tryQuickAdd = useCallback( + async (input: { + readonly kind: CatalogKind; + readonly url: string; + readonly title: string; + readonly domain: string; + /** The row's identity key — carries the PRODUCT, not just domain|kind: + * one domain can offer two products of the same kind (the Google + * Photos Library and Picker), and a domain-level key marked both cards + * added when either one was. */ + readonly rowKey: string; + readonly slug?: string; + readonly auth?: CatalogSurface["auth"]; + readonly specOverrides?: CatalogSurface["specOverrides"]; + }): Promise => { + const fn = quickAdders.current.get(KIND_TO_PLUGIN_KEY[input.kind] ?? input.kind); + if (!fn) return false; + const rowKey = input.rowKey; + setQuickAddingKeys((previous) => new Set(previous).add(rowKey)); + trackEvent("integration_add_started", { + plugin_key: input.kind, + via: "catalog", + catalog_domain: input.domain, + }); + const result = await fn({ + url: input.url, + name: input.title, + domain: input.domain, + ...(input.slug ? { slug: input.slug } : {}), + ...(input.auth?.header ? { authHeader: input.auth.header } : {}), + ...(input.auth?.kind ? { authKind: input.auth.kind } : {}), + ...(input.specOverrides ? { specOverrides: input.specOverrides } : {}), + }); + setQuickAddingKeys((previous) => { + const next = new Set(previous); + next.delete(rowKey); + return next; + }); + if (!result.ok) return false; + setQuickAddedSlugs((previous) => new Map(previous).set(rowKey, result.slug)); + return true; + }, + [], + ); + + const pickCatalogEntry = useCallback( + async ( + entry: CatalogSearchEntry, + kind: CatalogKind, + title: string, + rowKey: string, + // The EXACT surface this card was built from. Never re-derived from + // `kind`: one domain can carry two same-kind products (Google Photos + // Library and Picker), and a kind lookup silently merged the clicked + // card's URL with the FIRST surface's slug, auth, and overrides. + surface?: CatalogSurface, + ) => { + const knownUrl = surface?.url; + if (knownUrl) { + const added = await tryQuickAdd({ + kind, + url: knownUrl, + title, + domain: entry.domain, + rowKey, + ...(surface ? { slug: surface.slug } : {}), + ...(surface?.auth ? { auth: surface.auth } : {}), + ...(surface?.specOverrides ? { specOverrides: surface.specOverrides } : {}), + }); + if (added) return; + goToAdd({ + kind, + url: knownUrl, + domain: entry.domain, + ...(surface ? { slug: surface.slug } : {}), + ...(surface?.auth ? { auth: surface.auth } : {}), + ...(surface?.specOverrides ? { specOverrides: surface.specOverrides } : {}), + }); + return; + } + if (resolvingDomain !== null) return; + setResolvingDomain(entry.domain); + setRowError(null); + // The row's product name disambiguates same-kind surfaces in the doc + // (the registry row itself was kinds-only, so the name is all we have). + const exit = await Effect.runPromiseExit( + resolveConnectTarget(entry.domain, [kind], entry.name ?? undefined), + ); + setResolvingDomain(null); + if (Exit.isFailure(exit) || !exit.value) { + setRowError({ + key: rowKey, + message: "Couldn't load connect details. Paste its URL above instead.", + }); + return; + } + const target = exit.value; + const added = await tryQuickAdd({ + kind: target.kind, + url: target.url, + title, + domain: entry.domain, + rowKey, + ...(target.slug ? { slug: target.slug } : {}), + }); + if (added) return; + goToAdd({ + kind: target.kind, + url: target.url, + domain: entry.domain, + ...(target.slug ? { slug: target.slug } : {}), + }); + }, + [goToAdd, resolvingDomain, tryQuickAdd], + ); + + const pickPreset = useCallback( + (entry: PresetEntry) => { + trackEvent("integration_add_started", { + plugin_key: entry.pluginKey, + via: "preset", + preset_id: entry.preset.id, + }); + void navigate({ + to: "/{-$orgSlug}/integrations/add/$pluginKey", + params: { pluginKey: entry.pluginKey }, + search: { preset: entry.preset.id }, + }); + }, + [navigate], + ); + + // --- Preset rows --------------------------------------------------------- + // + // Only presets WITHOUT a connect URL remain cards: a preset with a URL is a + // registry row's job now, while a local-process server (Chrome DevTools over + // stdio) has no registry representation yet. + const presetRows = useMemo(() => { + const rows: Row[] = []; + for (const entry of allPresets) { + // A URL preset the registry lists is the registry row's job. A custom + // deployment preset has no registry row — hiding it would make a + // private API undiscoverable, so it keeps its card. + if ( + (entry.preset.url !== undefined || entry.preset.endpoint !== undefined) && + entry.preset.registryListed === true + ) + continue; + if (text.length > 0) { + const corpus = + `${entry.preset.name} ${entry.preset.summary ?? ""} ${entry.preset.family ?? ""} ${entry.pluginLabel}`.toLowerCase(); + if (!corpus.includes(text)) continue; + } + const surface = SURFACE_WORD[entry.pluginKey] ?? entry.pluginLabel; + const title = withSurface(entry.preset.name, surface); + const kindKey = entry.pluginKey === "google" ? "openapi" : entry.pluginKey; + rows.push({ + key: `preset-${entry.pluginKey}-${entry.preset.id}`, + testId: `preset-${entry.pluginKey}-${entry.preset.id}`, + title, + kindKey, + ...(entry.preset.summary ? { description: entry.preset.summary } : {}), + ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}), + onSelect: () => pickPreset(entry), + // The rendered title is also what the add flow derives the namespace + // from, so the card recognises an add made through it even when the + // preset declares no defaultSlug and its bare name would miss. + added: isAdded(entry.pluginKey, entry.preset.defaultSlug, entry.preset.name, title), + busy: false, + }); + } + return rows; + }, [allPresets, text, pickPreset, isAdded]); + + // --- Catalog rows: one per (service, surface) ----------------------------- + const catalogRows = useMemo(() => { + let rows = catalogEntries.flatMap((entry): readonly Row[] => { + const pretty = entry.name ?? domainDisplayName(entry.domain); + const description = entry.description ? tidyDescription(entry.description) : undefined; + // Prefer the registry's own per-surface records, and within them only + // the surfaces that carry a connect target: the registry can know a + // surface EXISTS without knowing where it lives (conjur.org's OpenAPI + // has no recorded spec URL), and a card whose Add can only fail is + // worse than no card. Kinds-only entries (older registries, stubs) keep + // the resolve-on-click fallback. + const surfaces: readonly (CatalogSurface | { readonly kind: CatalogKind })[] = + entry.surfaces && entry.surfaces.length > 0 + ? entry.surfaces.filter((surface) => surface.url) + : entry.kinds.map((kind) => ({ kind })); + return surfaces.map((surface): Row => { + const known = "slug" in surface ? surface : null; + const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind]; + const title = withSurface(pretty, word); + // Product identity, not just domain|kind: two same-kind products can + // share a domain (Google Photos Library and Picker). + const rowKey = `${entry.domain}|${surface.kind}|${known?.slug ?? pretty}`; + const quickAddedSlug = quickAddedSlugs.get(rowKey); + // Same candidate set as `added`: namespaces come from the registry + // slug (quick add), the domain, or the display name (classic add + // pages), depending on how the integration got here. + const viewSlug = + quickAddedSlug ?? installedSlugFor(surface.kind, known?.slug, entry.domain, title); + // Slug in the key: one domain can carry many product surfaces of the + // same kind (Microsoft Graph's workloads all live on one domain). + return { + key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`, + testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`, + title, + kindKey: surface.kind, + domain: entry.domain, + ...(description ? { description } : {}), + iconUrl: + (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10), + onSelect: () => + void pickCatalogEntry(entry, surface.kind, title, rowKey, known ?? undefined), + added: + quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug, entry.domain, title), + ...(quickAddedSlug !== undefined ? { freshlyAdded: true } : {}), + ...(viewSlug ? { viewSlug } : {}), + busy: resolvingDomain === entry.domain || quickAddingKeys.has(rowKey), + ...(rowError?.key === rowKey ? { error: rowError.message } : {}), + }; + }); + }); + // A registry row for something a preset already offers is a worse copy of + // it: same service, no auth template, no health check. Matched on + // productKey — normalized name plus kind — because neither titles nor + // domains line up reliably: the Gmail preset's icon is Google's (so a + // domain comparison let a second "Gmail API" through), and the GitHub + // preset says "GitHub REST" where the registry row says "GitHub API". + const presetKeys = new Set(presetRows.map((row) => productKey(row.title, row.kindKey))); + rows = rows.filter((row) => !presetKeys.has(productKey(row.title, row.kindKey))); + if (text.length === 0) return rows; + // A name match beats a mention in the blurb: searching "gmail" should not + // rank a CRM that merely describes itself as living inside Gmail above + // Gmail. Stable within each group, so the registry's own order survives. + const isNamed = (row: Row) => `${row.title} ${row.domain ?? ""}`.toLowerCase().includes(text); + return [...rows.filter(isNamed), ...rows.filter((row) => !isNamed(row))]; + }, [ + catalogEntries, + isAdded, + installedSlugFor, + quickAddedSlugs, + quickAddingKeys, + resolvingDomain, + pickCatalogEntry, + text, + presetRows, + rowError, + ]); + + // Local-process cards hold no rank of their own, so each slots in where its + // title falls alphabetically among the surrounding rows — pinning them to + // the top of a popularity-ranked list gave Chrome DevTools pride of place + // over everything. They also WAIT for the catalog: painting a lone Chrome + // DevTools card while the registry loads reads as a one-item catalog, then + // reflows. When the registry is unreachable they render anyway, so the page + // degrades to the cards that still work. + const results = useMemo(() => { + if (catalog.loading && catalogRows.length === 0 && !catalog.failed) return catalogRows; + const merged = [...catalogRows]; + for (const preset of presetRows) { + const at = merged.findIndex( + (row) => row.title.localeCompare(preset.title, undefined, { sensitivity: "base" }) > 0, + ); + merged.splice(at === -1 ? merged.length : at, 0, preset); + } + // What you already have leads the list — those cards are the ones with a + // state worth seeing (View). Stable within each half, and fresh + // quick-adds hold their place until the next visit. + const floats = (row: Row) => row.added && row.freshlyAdded !== true; + // Installed integrations whose registry rows are NOT in the loaded page + // still belong at the front — "added first" is about the user's catalog, + // not about which sixty rows the registry ranked highest. Synthesized + // from the integration record itself; a loaded row that matched an + // installed integration claims its slug, so nothing appears twice. + const claimed = new Set(merged.flatMap((row) => (row.viewSlug ? [row.viewSlug] : []))); + const installedList: readonly Integration[] = AsyncResult.isSuccess(installed) + ? installed.value + : []; + const installedRows: readonly Row[] = installedList + .filter( + (row) => + row.canRemove && + (row.kind === "mcp" || row.kind === "openapi" || row.kind === "graphql") && + !claimed.has(String(row.slug)) && + (text.length === 0 || + row.name.toLowerCase().includes(text) || + String(row.slug).includes(text)), + ) + .map((row): Row => { + // The SAME icon cascade the sidebar runs — preset icon by exact + // identity, else the logo proxy from the integration's own URL. Two + // resolvers for one integration is how the sidebar and the picker + // ended up disagreeing about what DeepWiki looks like. + const iconUrl = + integrationPresetIconUrl( + { + id: String(row.slug), + kind: row.kind, + name: row.name, + ...(row.displayUrl ? { url: row.displayUrl } : {}), + }, + integrationPlugins, + ) ?? + integrationFaviconUrl( + row.displayUrl ?? + integrationInferredUrl({ id: String(row.slug), name: row.name }) ?? + undefined, + 10, + ); + return { + key: `installed-${String(row.slug)}`, + testId: `installed-${String(row.slug)}`, + title: row.name, + kindKey: row.kind, + ...(row.description && row.description !== row.name + ? { description: tidyDescription(row.description) } + : {}), + ...(iconUrl ? { iconUrl } : {}), + onSelect: () => {}, + added: true, + viewSlug: String(row.slug), + busy: false, + }; + }); + return [...installedRows, ...merged.filter(floats), ...merged.filter((row) => !floats(row))]; + }, [ + presetRows, + catalogRows, + catalog.loading, + catalog.failed, + installed, + text, + integrationPlugins, + ]); + + // Presets are local, so a list that already has them is not empty — show + // skeletons only when there is genuinely nothing on screen yet. + const loading = catalog.loading && results.length === 0; + + return ( + // PageContainer scrolls the whole page; this page pins the header and + // search, and ONLY the results grid scrolls — an endless list makes the + // page scrollbar meaningless and drags the search box off screen. +
+ {quickAddCapablePlugins(integrationPlugins).map((plugin) => ( + + ))} +
+ + +
+
+ + { + setQuery((event.target as HTMLInputElement).value); + setError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter" && isUrl) void handleDetect(); + }} + placeholder="Search integrations, or paste a URL…" + aria-label="Search integrations, or paste a URL" + disabled={detecting} + // 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 + autoFocus + className="h-11 pl-9 text-sm" + /> +
+ {isUrl ? ( + + ) : null} +
+ + {/* Above the results, not below: an endless list has no reachable + bottom, and this is the escape hatch for exactly the person the + list is failing. One quiet line; the label carries the action, so + the chips stay bare format names. */} +
+ Start from scratch: +
+ {integrationPlugins.map( + (plugin: IntegrationPlugin): ReactNode => ( + // oxlint-disable-next-line react/forbid-elements -- a chip, not a Button variant + + ), + )} +
+ + Can't find it? Paste its URL above. + +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ +
+
+ {loading ? ( +
+ {Array.from({ length: 9 }).map((_, index) => ( + + ))} +
+ ) : results.length === 0 ? ( +

+ {catalog.failed + ? "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." + : "Nothing matches that. Paste the URL of an MCP server, OpenAPI spec, or GraphQL endpoint to add it directly."} +

+ ) : ( +
+ {results.map((row) => ( + + ))} + {catalog.loadingMore + ? Array.from({ length: 3 }, (_, index) => ( + + )) + : null} + {catalog.hasMore ? ( +
+ +
+ ) : null} +
+ )} +
+
+
+ ); +} diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index 451a91174..a08faeaa6 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -1,30 +1,15 @@ -import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react"; -import { Link, useNavigate } from "@tanstack/react-router"; -import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Suspense, useMemo, type ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import { PlusIcon } from "lucide-react"; -import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared"; -import { - useIntegrationPlugins, - type IntegrationPlugin, - type IntegrationPreset, -} from "@executor-js/sdk/client"; -import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms"; +import type { Integration } from "@executor-js/sdk/shared"; +import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/client"; +import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { McpInstallCard } from "../components/mcp-install-card"; import { Button } from "../components/button"; import { PageContainer, PageHeader } from "../components/page"; -import { Badge } from "../components/badge"; -import { Input } from "../components/input"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "../components/dialog"; import { CardStack, CardStackContent, @@ -32,7 +17,6 @@ import { CardStackEntryActions, CardStackEntryContent, CardStackEntryDescription, - CardStackEntryMedia, CardStackEntryTitle, CardStackHeader, } from "../components/card-stack"; @@ -41,18 +25,7 @@ import { integrationInferredUrl, integrationPresetIconUrl, } from "../components/integration-favicon"; -import { PresetIcon } from "../components/preset-icon"; import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping"; -import { - availableCatalogKinds, - catalogLogoUrl, - filterCatalogEntries, - presetDomains, - resolveConnectTarget, - useCatalogSearch, - type CatalogKind, - type CatalogSearchEntry, -} from "../lib/integrations-sh-catalog"; import { IntegrationHealthSummary } from "../components/integration-health-summary"; import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; import { Skeleton } from "../components/skeleton"; @@ -67,17 +40,6 @@ const KIND_TO_PLUGIN_KEY: Record = { googleDiscovery: "google", }; -const detectionRank: Record = { - high: 3, - medium: 2, - low: 1, -}; - -const bestDetection = ( - results: readonly IntegrationDetectionResult[], -): IntegrationDetectionResult | undefined => - [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; - // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- @@ -86,7 +48,6 @@ export function IntegrationsPage() { useExecutorDocumentTitle("Integrations"); const integrations = useAtomValue(integrationsOptimisticAtom); const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); - const [connectOpen, setConnectOpen] = useState(false); return ( @@ -94,16 +55,14 @@ export function IntegrationsPage() { title="Integrations" description="Tool providers available in this workspace." actions={ - } /> @@ -124,14 +83,7 @@ export function IntegrationsPage() { ), onSuccess: ({ value }) => { if (value.length === 0) { - return ( - { - setConnectOpen(true); - trackEvent("integration_connect_dialog_opened"); - }} - /> - ); + return ; } return ( @@ -142,176 +94,15 @@ export function IntegrationsPage() { }, }) )} - - ); } -// --------------------------------------------------------------------------- -// Connect dialog — URL detection + manual plugin chooser + presets -// --------------------------------------------------------------------------- - -// Heuristic: the input either looks like a URL (auto-detect) or a free-text -// search query (filter the preset list). Anything with a scheme, slash, or -// host-with-TLD is treated as a URL; everything else is search. -const looksLikeUrl = (raw: string): boolean => { - const v = raw.trim(); - if (v.length === 0) return false; - if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true; - if (v.includes("/")) return true; - if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true; - return false; -}; - -export function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) { - const integrationPlugins = useIntegrationPlugins(); - const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); - const navigate = useNavigate(); - - const [query, setQuery] = useState(""); - const [detecting, setDetecting] = useState(false); - const [error, setError] = useState(null); - - const isUrl = looksLikeUrl(query); - const presetSearch = isUrl ? "" : query; - - const closeAndReset = useCallback(() => { - setQuery(""); - setError(null); - setDetecting(false); - props.onOpenChange(false); - }, [props]); - - const handleDetect = useCallback(async () => { - const trimmed = query.trim(); - if (!trimmed) return; - setDetecting(true); - setError(null); - // Detection is read-only — it inspects a URL and returns candidates without - // mutating the catalog, so it invalidates nothing. - const exit = await doDetect({ - payload: { url: trimmed }, - reactivityKeys: [], - }); - if (Exit.isFailure(exit)) { - trackEvent("integration_detect_submitted", { success: false }); - setError("Detection failed. Try adding an integration manually."); - setDetecting(false); - return; - } - const results = exit.value; - if (results.length === 0) { - trackEvent("integration_detect_submitted", { success: false }); - setError("Could not detect an integration type from this URL. Try adding manually."); - setDetecting(false); - return; - } - const detected = bestDetection(results); - if (!detected) { - trackEvent("integration_detect_submitted", { success: false }); - setError("Could not detect an integration type from this URL. Try adding manually."); - setDetecting(false); - return; - } - trackEvent("integration_detect_submitted", { - success: true, - detected_kind: detected.kind, - confidence: detected.confidence, - }); - const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind] ?? detected.kind; - if (integrationPlugins.some((p) => p.key === pluginKey)) { - trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" }); - closeAndReset(); - void navigate({ - to: "/{-$orgSlug}/integrations/add/$pluginKey", - params: { pluginKey }, - search: { url: trimmed, namespace: detected.slug }, - }); - } else { - setError(`Detected integration type "${detected.kind}" but no plugin is available for it.`); - setDetecting(false); - } - }, [query, doDetect, navigate, integrationPlugins, closeAndReset]); - - return ( - { - if (!open) closeAndReset(); - else props.onOpenChange(open); - }} - > - - - Connect an integration - - Search the preset library, or paste a URL to auto-detect. - - - -
-
-
- { - setQuery((e.target as HTMLInputElement).value); - setError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter" && isUrl) void handleDetect(); - }} - placeholder="Search or paste a URL…" - disabled={detecting} - className="flex-1" - /> - {isUrl && ( - - )} -
- {error &&

{error}

} -
- -
-

Or add manually

-
- {integrationPlugins.map((p) => ( - { - trackEvent("integration_add_started", { plugin_key: p.key, via: "manual" }); - closeAndReset(); - }} - className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted" - > - {p.label} - - ))} -
-
- - -
-
-
- ); -} - // --------------------------------------------------------------------------- // Empty state // --------------------------------------------------------------------------- -function EmptyIntegrations(props: { onConnect: () => void }) { +function EmptyIntegrations() { return (
@@ -321,230 +112,19 @@ function EmptyIntegrations(props: { onConnect: () => void }) {

Connect an integration to start curating tools.

-
); } -// --------------------------------------------------------------------------- -// Preset grid (for inside the Connect dialog) -// --------------------------------------------------------------------------- - -type PresetEntry = { - preset: IntegrationPreset; - pluginKey: string; - pluginLabel: string; -}; - -const CATALOG_KIND_LABEL: Record = { - mcp: "MCP", - openapi: "OpenAPI", - graphql: "GraphQL", -}; - -function PresetGrid(props: { - plugins: readonly IntegrationPlugin[]; - onPick: () => void; - /** Controlled filter query forwarded from the dialog's unified - * search/URL input. Empty string disables filtering. */ - searchQuery?: string; -}) { - const navigate = useNavigate(); - const allPresets = useMemo(() => { - const entries: PresetEntry[] = []; - for (const plugin of props.plugins) { - for (const preset of plugin.presets ?? []) { - entries.push({ - preset, - pluginKey: plugin.key, - pluginLabel: plugin.label, - }); - } - } - return entries; - }, [props.plugins]); - - const query = (props.searchQuery ?? "").trim(); - - const filtered = useMemo(() => { - const q = query.toLowerCase(); - if (q.length === 0) return allPresets; - return allPresets.filter(({ preset, pluginLabel }) => { - const corpus = - `${preset.name} ${preset.summary ?? ""} ${preset.family ?? ""} ${preset.specFormat ?? ""} ${pluginLabel}`.toLowerCase(); - return corpus.includes(q); - }); - }, [allPresets, query]); - - // Long-tail search over the public integrations.sh registry, shown under the - // curated presets. Domains a preset already covers stay preset-only. - const catalog = useCatalogSearch(query); - const excludeDomains = useMemo(() => presetDomains(props.plugins), [props.plugins]); - const availableKinds = useMemo(() => availableCatalogKinds(props.plugins), [props.plugins]); - const catalogEntries = useMemo( - () => filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds }), - [catalog.entries, excludeDomains, availableKinds], - ); - const [resolvingDomain, setResolvingDomain] = useState(null); - const [catalogError, setCatalogError] = useState(null); - - const pickCatalogEntry = useCallback( - async (entry: CatalogSearchEntry) => { - if (resolvingDomain !== null) return; - setResolvingDomain(entry.domain); - setCatalogError(null); - const exit = await Effect.runPromiseExit(resolveConnectTarget(entry.domain, entry.kinds)); - setResolvingDomain(null); - if (Exit.isFailure(exit)) { - setCatalogError( - `Couldn't load connect details for ${entry.domain}. Paste a URL above instead.`, - ); - return; - } - const target = exit.value; - if (!target) { - setCatalogError( - `No connectable endpoint is on record for ${entry.domain}. Paste a URL above to detect one.`, - ); - return; - } - trackEvent("integration_add_started", { - plugin_key: target.kind, - via: "catalog", - catalog_domain: entry.domain, - }); - props.onPick(); - void navigate({ - to: "/{-$orgSlug}/integrations/add/$pluginKey", - params: { pluginKey: target.kind }, - search: { url: target.url, ...(target.slug ? { namespace: target.slug } : {}) }, - }); - }, - [navigate, props, resolvingDomain], - ); - - const showCatalogSection = query.length > 0 && (catalogEntries.length > 0 || catalog.loading); - - if (allPresets.length === 0) return null; - - return ( -
-

Popular integrations

- - {/* Fixed height keeps the dialog stable as the user filters; the - * inner area scrolls when the list overflows and shows an empty - * state when no presets match. */} - - {filtered.length === 0 && !showCatalogSection ? ( -
-

No matching integrations

-

- Paste a URL above to auto-detect, or pick an integration type manually. -

-
- ) : ( - filtered.map(({ preset, pluginKey, pluginLabel }) => { - const search: Record = { preset: preset.id }; - if (preset.url) search.url = preset.url; - return ( - - { - trackEvent("integration_add_started", { - plugin_key: pluginKey, - via: "preset", - preset_id: preset.id, - }); - props.onPick(); - }} - > - - - - - } - /> - - - {preset.name} - {preset.summary} - - - {pluginLabel} - - - - ); - }) - )} - {showCatalogSection && ( - <> - {catalogEntries.map((entry) => ( - - - - ))} - {catalog.loading && - catalogEntries.length === 0 && - Array.from({ length: 3 }).map((_, i) => ( -
- -
- - -
- -
- ))} - - )} -
-
- {catalogError &&

{catalogError}

} -
- ); -} - // --------------------------------------------------------------------------- // Integration grid — flat list of catalog integrations, click-through to detail // --------------------------------------------------------------------------- diff --git a/packages/react/src/routes/integrations.add.$pluginKey.tsx b/packages/react/src/routes/integrations.add.$pluginKey.tsx index 4da474efe..aade1e073 100644 --- a/packages/react/src/routes/integrations.add.$pluginKey.tsx +++ b/packages/react/src/routes/integrations.add.$pluginKey.tsx @@ -8,6 +8,14 @@ const SearchParams = Schema.toStandardSchemaV1( url: Schema.optional(Schema.String), preset: Schema.optional(Schema.String), namespace: Schema.optional(Schema.String), + // Registry-declared credential placement, e.g. "Authorization: {api_key}". + // Carried for surfaces whose connect target can't describe its own auth + // (GraphQL endpoints have no spec document). + authHeader: Schema.optional(Schema.String), + authNote: Schema.optional(Schema.String), + authKind: Schema.optional(Schema.String), + // JSON-encoded RFC 6902 patch the registry says to apply to the spec. + specOverrides: Schema.optional(Schema.String), }), ); @@ -15,9 +23,19 @@ export const Route = createFileRoute("/{-$orgSlug}/integrations/add/$pluginKey") validateSearch: SearchParams, component: () => { const { pluginKey } = Route.useParams(); - const { url, preset, namespace } = Route.useSearch(); + const { url, preset, namespace, authHeader, authNote, authKind, specOverrides } = + Route.useSearch(); return ( - + ); }, }); diff --git a/packages/react/src/routes/integrations.browse.tsx b/packages/react/src/routes/integrations.browse.tsx new file mode 100644 index 000000000..416a07d14 --- /dev/null +++ b/packages/react/src/routes/integrations.browse.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { IntegrationBrowsePage } from "../pages/integration-browse"; + +export const Route = createFileRoute("/{-$orgSlug}/integrations/browse")({ + component: IntegrationBrowsePage, +}); diff --git a/packages/react/src/routes/routeTree.gen.ts b/packages/react/src/routes/routeTree.gen.ts index f4934a198..f742d7aac 100644 --- a/packages/react/src/routes/routeTree.gen.ts +++ b/packages/react/src/routes/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as DotPoliciesRouteImport } from './policies' import { Route as DotArtifactsRouteImport } from './artifacts' import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' +import { Route as DotIntegrationsDotbrowseRouteImport } from './integrations.browse' import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug' import { Route as DotArtifactsDotartifactIdRouteImport } from './artifacts.$artifactId' @@ -70,6 +71,12 @@ const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({ path: '/{-$orgSlug}/resume/$executionId', getParentRoute: () => rootRouteImport, } as any) +const DotIntegrationsDotbrowseRoute = + DotIntegrationsDotbrowseRouteImport.update({ + id: '/{-$orgSlug}/integrations/browse', + path: '/{-$orgSlug}/integrations/browse', + getParentRoute: () => rootRouteImport, + } as any) const DotIntegrationsDotnamespaceRoute = DotIntegrationsDotnamespaceRouteImport.update({ id: '/{-$orgSlug}/integrations/$namespace', @@ -112,6 +119,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute @@ -128,6 +136,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute @@ -145,6 +154,7 @@ export interface FileRoutesById { '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute @@ -163,6 +173,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -179,6 +190,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -195,6 +207,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -211,6 +224,7 @@ export interface RootRouteChildren { DotIndexRoute: typeof DotIndexRoute DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute + DotIntegrationsDotbrowseRoute: typeof DotIntegrationsDotbrowseRoute DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute DotPluginsDotpluginIdDotsplatRoute: typeof DotPluginsDotpluginIdDotsplatRoute @@ -281,6 +295,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotResumeDotexecutionIdRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/integrations/browse': { + id: '/{-$orgSlug}/integrations/browse' + path: '/{-$orgSlug}/integrations/browse' + fullPath: '/{-$orgSlug}/integrations/browse' + preLoaderRoute: typeof DotIntegrationsDotbrowseRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/integrations/$namespace': { id: '/{-$orgSlug}/integrations/$namespace' path: '/{-$orgSlug}/integrations/$namespace' @@ -353,6 +374,7 @@ const rootRouteChildren: RootRouteChildren = { DotIndexRoute: DotIndexRoute, DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute, DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute, + DotIntegrationsDotbrowseRoute: DotIntegrationsDotbrowseRoute, DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute, DotIntegrationsDotaddDotpluginKeyRoute: DotIntegrationsDotaddDotpluginKeyRoute, diff --git a/plans/kill-plugin-system.md b/plans/kill-plugin-system.md new file mode 100644 index 000000000..0b1c6ae66 --- /dev/null +++ b/plans/kill-plugin-system.md @@ -0,0 +1,515 @@ +# Kill the plugin system — in-place migration plan + +Decision (Rhys, 2026-07-27): abandon the big-bang rework +(`UsefulSoftwareCo/executor-rework`, cloned read-only at +`.reference/executor-rework`); fix this repo in place over a sequence of PRs, +each keeping format/lint/typecheck/test/e2e green. Priority order: **data +models first**, then the plugin kill, then the concept-package (apps) model, +then the minimal SDK / platform split. + +RESEQUENCED (Rhys, 2026-07-28): **Phase 3 (apps/concept-package model) now +lands BEFORE Phase 2 (plugin kill), side by side with the protocol plugins.** +Rationale: the new model's tables are greenfield and additive — landing it +first means the legacy apps tables never need an in-place migration; the whole +model swaps underneath while openapi/mcp/graphql keep running as plugins. +Structurally sound because (a) the kernel + MCP host are already plugin-free, +so the new model's runtime never touched the registry, and (b) the apps plugin +already exercises every extension point, so the new model ships as the apps +plugin's new internals wearing the same costume. When 2.3 later converts the +four kinds, apps converts as a stable, proven model instead of mid-rewrite. +Phase 0 and 2.1 remain freely parallel — they don't touch apps. + +Hard line: side-by-side means new-apps-model alongside the _protocol plugins_, +never two app models with dual read paths inside the apps kind. Existing app +sources get a one-off migration to published-source form (enumerated +invariants, explicit approval, no permanent compatibility path). Accepted +cost: the new model's HTTP surface temporarily wears plugin-route ceremony +(HttpApiGroup + handler layers) that 2.2/2.6 later deletes — wiring, not +model. The authoring SDK (`defineApp`/`defineProvider`/`defineTool`) goes +public early; its boundaries take Standard Schema (StandardSchemaV1 & +StandardJSONSchemaV1), never Effect Schema, and Phase 4's vocabulary rules +(opaque owner refs, no "tenant") bind on it from day one. + +## Diagnosis (verified against the code) + +**The plugin system is a fiction.** `packages/core/sdk/src/plugin.ts` (~850 +lines, ~20 optional hooks) pretends the plugin set is open, but: + +- `Integration.kind` _is_ the plugin id string; all hot paths dispatch via + `runtimes.get(row.plugin_id)` (`executor.ts`). +- Core and React hardcode the closed set anyway: `migration-spec.ts` hardcodes + plugin ids; `packages/react` has `KIND_TO_PLUGIN_KEY` maps and an + `if (pluginId === "toolkits")` route branch. +- Nobody ever shipped a fourth protocol plugin, and the two provider plugins + (google/microsoft) were already collapsed into openapi (#1366). +- Whole packages exist only to support the open set: `plugin-routes.ts` + type-union machinery, `@executor-js/config`'s jsonc+jiti loader, + `packages/core/vite-plugin`'s virtual client module. The jsonc plugin path is + vestigial — no `executor.jsonc` exists anywhere in the repo, no docs mention + it. +- Toolkits (~880 LoC of pure curation + the single `toolPolicyProvider` slot) + and apps (~6.8k custom-tools gateway) are first-party capabilities wearing a + plugin costume. Kernel packages and `packages/hosts/mcp` have zero plugin + references — the indirection is not load-bearing for the real surfaces. + +**The data model is connection-first, not user-first** +(`packages/core/sdk/src/core-schema.ts`): + +- No user/subject table. `subject` is an opaque partition-key string smeared + across owned tables; a user "exists" only if they have a connection row. +- Disconnect is a hard delete (`connectionsRemove`) — a signed-out user is + bit-identical to one who never connected. "Which users are signed out" is + structurally unanswerable. +- Every Executor is bound to one `{tenant, subject}` via `owner-policy.ts`; + there is no cross-subject read path, so no admin view. +- Toolkit state lives in generic `plugin_storage` KV, not core tables. +- Cloud's real identity tables (WorkOS accounts/organizations) have no join to + `subject`. + +**What to salvage from the rework** (its model docs are the valuable artifact; +its implementation ported only ~10 of 131 legacy e2e scenarios — that's why it +failed, and why this plan is incremental): + +- Apps-as-published-source: `defineApp`/`defineProvider`/`defineTool`/ + `defineToolGroup`, immutable content-hashed versions, manifest derived by + executing the source, rollback = pointer move. +- Generators: OpenAPI/MCP specs generate the _same_ app source a human would + write, with a re-verifiable `GeneratorClaim` (first-party vs third-party). +- Minimal SDK boundary: `createExecutor`/`createRemoteExecutor`, no Effect + crossing the boundary, MCP exposure returns a standard MCP SDK server. +- Custody separation: secrets never on the wire; opaque owner refs. + +## Constraints discovered (do not relearn these) + +1. **Published packages make this semver-major.** `scripts/publish-packages.ts` + publishes `@executor-js/{config,sdk}` and plugins + `{example,file-secrets,graphql,keychain,mcp,onepassword,openapi}` — killing + `definePlugin` is a breaking release for seven packages. Changesets + + deprecation story required; `publish-packages.ts`, `pkg-pr-new.yml`, + `check-changelog-stubs.ts`, and `examples/*` all have hardcoded lists. +2. **`plugin_id = 'google'` is a live persisted value with no runtime.** The + provider-service-split migration skips orgs with missing spec blobs + (`sqlite.test.ts` asserts survivors). The `googleDiscovery → "google"` + entries in React are compensating, not dangling. The closed SourceType + switch must map `"google"`/`"googleDiscovery"` → openapi or degraded rows + become hard failures. +3. **FumaDB is not versioned in practice.** All hosts pin `version: "1.0.0"`; + runtime bring-up is `ensureDrizzleRuntimeSchemaFromTables` (CREATE TABLE IF + NOT EXISTS + additive nullable ALTERs). New tables/nullable columns are free + on local/self-host/D1. **Cloud is the exception**: hand-edit + `apps/cloud/src/db/executor-schema.ts` + drizzle-kit migration + + `db:migrate:prod`; `db.schema.test.ts` enforces the mirror, so "forgot + cloud" fails tests. +4. **93 e2e files import `composePluginApi`** purely to type the client. Keep + it as a deprecated argument-ignoring shim returning the static + `FullExecutorApi`; sweep the call sites mechanically in one final PR. +5. **Keep the `plugin_id` column name** (four tables + blob namespace format + `o:/`). Renaming to `source_kind` is a cross-host + migration for zero behavior. Retype on the read side only. +6. **URL contracts:** `/integrations/add/$pluginKey` is navigated directly by + e2e; `/plugins/desktop-settings/` is asserted by the desktop-packaged suite + and baked into a tool's returned URL. Keep shapes / add redirects. +7. **CI gaps:** PRs run cloud (4 shards) + selfhost e2e only; local/desktop + suites gate push-to-main. PRs touching desktop-settings or the local loader + need a manual local e2e run pre-merge. +8. **Toolkit selection filters _connections_, not just tools** + (`connectionsList` goes through the active policy provider). Preserve when + moving toolkits to core, or the toolkit e2e suites go red. + +## Phase 0 — declutter (3 PRs) + +- **0.1** Delete empty `packages/plugins/{google,microsoft}`; drop the unused + `@executor-js/config` dep from openapi/mcp/graphql; move + `provider-service-split` to `packages/migrations/` (keep package name — it's + imported by three host data-migration registries). In the React maps, keep + `google`/`googleDiscovery` → openapi with a comment citing constraint 2. +- **0.2** Bake google/microsoft catalogs + spec-format adapters in as openapi + defaults; strip the re-threading from all four host configs. Gate: + `provider-plugins-ui`, `google-health-checks`, `microsoft-*` scenarios. +- **0.3** Move desktop-settings (438-line Electron-IPC client, not a + one-liner) to a first-class desktop-only route in `packages/app`; update the + static tool's URL and the desktop-packaged e2e selector; delete the plugin. + +## Phase 1 — data model (4 PRs) — FIRST PRIORITY + +Design choices (least-invasive, verified feasible): + +- The new **subject table is `tenant`-scoped, not owner-scoped** — readable by + any executor bound to the tenant with **zero policy changes** (the + `integration` table already proves the pattern). +- Cross-subject connection reads: add `reach: "bound" | "tenant"` to + `ExecutorOwnerPolicyContext` — widens `ownerVisibilityCondition` for reads + only; `assertOwnerWritable`/`assertOwnerPatch` untouched, so admin reach can + never write. Only two construction sites exist (`executor.ts`, + `test-config.ts`). +- Identity stays with the hosts (WorkOS / Better Auth member lists already + exist behind `AccountProvider.listMembers`); the subject table is the missing + **join** between host identity and executor connections, not a new identity + system. Tolerate `subject = null` (pure-org) and `subject = "local"`. + +PRs: + +- **1.1** Add tenant-scoped `subject` table (id, tenant, external_id, + created_at, last_seen_at, status) to `coreTables` + cloud mirror + one + drizzle migration. No readers yet. +- **1.2** Populate: upsert in `makeScopedExecutor` + (`packages/core/api/src/server/scoped-executor.ts` — every HTTP request and + MCP session on all four hosts passes through it) and at connection-create. + Add to `purgeOrganizationData` + its `TENANT_TABLES` test. +- **1.3** `reach` mode + `admin.listSubjectsWithConnections`-shaped read on a + derived handle. Policy test proving admin reach cannot write. Scope includes + (customer ask, owner.com 2026-07-27): per-subject reads ("connections for + subject X"), not just the aggregate list, and a privileged org-level API key + (keys already carry `{accountId, organizationId}` — + `apps/cloud/src/auth/api-keys.ts`) that resolves to the tenant-reach + read-only handle so customers can inspect connections programmatically. +- **1.4** Connection lifecycle: nullable `status` column + (active/disconnected/revoked/expired) plus `status_changed_at`; disconnect + writes status instead of deleting. Wide blast radius — status predicate + through `findConnectionRow`, `connectionsList`, credential resolution, + `toolsList`, org purge; own e2e sweep (`connections-*`, `health-checks*`, + `oauth-*`). PRIORITY RAISED (customer, 2026-07-28): the customer's + block-startup-until-reconnect flow needs connected-then-disconnected to be + distinguishable from never-connected, which hard-delete makes impossible + today; expose status + statusChangedAt through the admin API when this + lands. + +Toolkits data does NOT move in this phase (it moves with its reader in 2.4 — +moving it earlier means either rewriting the plugin storage layer twice or a +forbidden dual read path). + +### Framing: product view vs platform view (Rhys, 2026-07-27) + +The two-view model for consumer-facing surfaces, longer-term: + +- **Product view** — the bound, per-individual executor every request gets + today (their connections, their tools). The default; unchanged. +- **Platform view** — an operator-level view attached alongside it: read-only, + cross-user, explicitly reached for with elevated access (an escape hatch, + never a mode product view drifts into). The `reach: "bound" | "tenant"` + split in 1.3 is this model's first implementation; 1.5's admin API and the + privileged key are its first surface. Later platform-view candidates (usage, + policy overview, cross-user health) land on the same handle. + +Cloud wrinkle: cloud consumers like owner.com don't bring their own owner ids — +their users authenticate through our WorkOS, so `subject` is _our_ accountId +and the customer correlates on their side. The opaque-owner-ids-in-the- +customer's-id-space story fully applies only to embedded/API consumers; for +cloud, the platform view (+ `external_id` on the subject table) is what makes +the correlation tractable. + +### Fast path: owner.com admin-visibility ask (ships from Phase 1 alone) + +The owner.com ask (admin inspects users' connections; API key that can do the +same; available-vs-connected icon grid; direct connect links) needs only +1.1 → 1.2 → 1.3 plus a thin delivery layer — no dependency on Phase 0 or the +plugin kill. Delivery PRs on top of 1.3: + +- **1.5** Public admin API surface: `GET .../admin/users`, + `GET .../admin/users/:externalId/connections` (integration slug + status + + last_health only — never item_ids/oauth fields), gated on the privileged key + / admin membership. Public paths say `users`, not `subjects`/`tenants` — see + "Vocabulary" below. The available-vs-connected diff is computable client-side + from this plus the existing integration catalog. +- **1.6** Connect deep links: stable + `/connect/:integrationSlug`-shaped URL that lands the arriving user in the + connect flow for that integration (auth-aware redirect). Uses integration + slugs, not plugin routes, so it survives Phase 2.5 unchanged. + + 1.4 (lifecycle status) upgrades the grid from binary connected/not to + expired/disconnected states but is not a blocker for first ship. + +## Phase 2 — kill the registry (6 PRs) + +- **2.1** Remove dynamic loading: delete `load-plugins.ts` from + `@executor-js/config` (keep the rest — jsonc integration config is a separate + feature), drop `loadPluginsFromJsonc` from `apps/local`, delete + `packages/core/vite-plugin`, replace `virtual:executor/plugins-client` with a + static module in `packages/app` (5 `__root.tsx` importers + 3 ambient + `.d.ts`), edit `turbo.json` `dev.dependsOn` + root `prepare`, fix + `packages/app/vite.config.ts`'s dangling jsonc path. Breaking changeset. +- **2.2** Static route list: `composePluginApi` becomes the deprecated shim + (constraint 4); hosts stop passing plugin tuples; delete + `apps/{cloud,host-selfhost}/src/plugins.ts` (exist only to hold + `PluginExtensionServices` typing). +- **2.3** The switch: closed internal `SourceType` interface implemented by + openapi/graphql/mcp/**apps (current model, unchanged)** as plain libraries, + replacing `runtimes.get`. All four kinds convert together — converting three + and leaving apps on the plugin path means carrying dual dispatch; rewriting + the apps model in the same PR is what killed the rework. Map + `"google"`/`"googleDiscovery"` → openapi. `plugin_id` column name unchanged. +- **2.4** Toolkits to core: core tables + one-off migration out of + `plugin_storage` (enumerated invariants, no dual path), `toolPolicyProvider` + slot → native policy input (one call site, one implementation, one test), + ordinary core routes, `activeToolkitSlug` becomes an executor option. + Preserve the connection-visibility filter (constraint 8). +- **2.5** React: four named kind renderers; move plugin React code (~2.9k LoC, + e.g. `AddMcpIntegration.tsx`) into `packages/react` — watch the peer-dep + edge for cycles; delete `defineClientPlugin`, the plugins context, both + `KIND_TO_PLUGIN_KEY` maps, and `/plugins/$pluginId/$` (with redirects for + desktop-settings + toolkits). Keep `/integrations/add/$pluginKey` URL shape. + Don't forget the second axis: `useSecretProviderPlugins` (secrets page). +- **2.6** Detach `CredentialProvider` from `PluginSpec` (the interface in + `provider.ts` is already narrow — secret-store plugins keep it as their + seam); delete `plugin.ts`, `plugin-routes.ts`, `plugin-example`, and the + shim; mechanical sweep of the 93 e2e files; changesets for all seven + published packages. + +## Phase 3 — custom = concept package (3–4 PRs) — NOW BEFORE PHASE 2 + +(See RESEQUENCED note at top: lands side by side with the protocol plugins, +inside the apps plugin's existing costume; Phase 2 kills the registry +afterward, with apps converting in 2.3 as an already-stable model.) + +Port the rework's app model in place as the `apps` source kind's new model: +published source, manifest derived by execution, content-hashed immutable +versions, rollback = pointer move. New tables are additive greenfield (same +pattern as 1.1: coreTables + cloud mirror + migration, no readers first). +Existing app sources migrate one-off to published-source form — no dual model +within the apps kind. Then the OpenAPI/MCP generators + `GeneratorClaim` as an +optional on-ramp (spec → generated app source); GraphQL generator later. +Long-term option (explicitly out of scope): protocol kinds themselves become +generators and "custom" is the only kind. + +### Feasibility findings (two deep read-throughs, 2026-07-28) + +**Current plugin** (~7.6k LoC): only ~2.65k encodes the old model (store +collections, sync state machine, publish/discover/descriptor, routes, +console client). ~2.6k is model-agnostic and survives verbatim: all three +sandbox executors, the bundler stack, bindings/bridge, authoring + +standard-schema, conformance. ~1.9k (hand-rolled git client + source +fetchers) hangs on the publish-input decision below. Storage is entirely +generic `plugin_storage` rows + content-addressed blobs — zero apps-specific +tables or migrations exist, so new tables are pure greenfield. Cloud prod is +git-source-only; **source text is never persisted today (only compiled +bundles)**, so the one-off migration re-fetches source from the recorded git +URLs (fetcher exists). Load-bearing invariants to preserve: tool addresses +`tools..org.published.` + the `__` naming rule, +app-slug↔integration-slug 1:1 guard, publish CAS +(`AppPublishConflictError`), tombstone-not-delete, token-in-provider-item +(never in the source row). Safety net: `custom-tools.test.ts` (534-line +cloud+selfhost console loop), local directory-picker, packed-CLI, desktop +e2e, ~2.9k LoC in-package tests. Pre-existing gaps flagged: R2 blobs never +GC'd and org purge misses them; cloud silently falls back to in-process +executor without the worker loader; host-cloudflare doesn't register apps. + +**Rework model** (portable tiers): T1 model proper ~3.9k LoC (authoring SDK, +publish-by-execution, projection, generator verification, storage port, +content hashing); T2 generators ~3.2k (fromOpenAPI 1k — complete; fromMCP +0.6k — computed group with real streamable-HTTP client; json-schema engine; +byte-for-byte GeneratorClaim verification; render endpoint); T4 host/registry +~2.9k explicitly skipped. Authoring is Standard Schema +(StandardSchemaV1 & StandardJSONSchemaV1) with raw-JSON-Schema literals as a +typed first-class alternative — matches our rule. Effect beta.93→beta.59 +delta is small (ConstraintDecoder → one-line fix; hand-roll +StandardJSONSchemaV1 ~10 lines; reuse core sha256Hex). NOT portable as-is: +rework `storage-db.ts` (write a new adapter over our tables against the +~90-line Storage port interface) and `engine.ts` (extract only ~700 LoC of +model-owned logic: computed-surface cache + requirement resolution + +runTool). Known rework gaps to fix at port time, not inherit: derive-time +execution is unsandboxed in-process (`code-executor.ts:23` records it) — our +port MUST run publish-time evaluation in the existing sandbox `collect` path +from day one; fan-in computed groups unsupported; OAuth discovery +unsupported. + +**Semantic deltas — decided by Rhys before 3.1 specs:** + +1. **Handler credential contract (the deep one).** Current: handlers get an + opaque callable `AppIntegrationClient` proxy that re-enters + `ctx.execute` — app code never sees credentials, can only call other + executor tools. Rework (D49): engine resolves credentials once and hands + the handler **credential fields as data** (`ResolvedConnection.fields`, + e.g. access_token) for direct HTTP — that's what makes generators + possible (generated code does raw fetch with auth) and what + `defineProvider` exists for. Not either/or in principle (a tool-call + bridge can coexist as a second requirement kind later), but the ported + model's primary contract is fields-as-data per the rework. +2. **Publish input.** Rework publish takes `SourceFiles` (push-based). + Decision: keep git/local-directory as _fetchers that produce + SourceFiles_ (preserves prod sources, the e2e suite, and the migration + path; git-client survives), with direct push publish added as the new + API. Delete-git-later stays open. +3. **App identity** moves from caller-supplied slug to the `defineApp` + export inside the source (rework shape). +4. **Versioning**: content hash of source IS the version identity; the + caller-supplied `sourceRef` + descriptor v6 die with the old model. +5. **Ownership**: app/version rows carry `space` and NO owner — spaces + curate apps, principals own custody (connections stay on the legacy + owner model in this phase; the opaque-OwnerRef swap is Phase 4). + +### Phase 3 PR sequence (revised) + +- **3.1** Port the model core as internal packages, no readers: authoring + SDK + runtime brands, publish pipeline (evaluation routed through the + sandbox `collect` seam), Storage port + adapter, greenfield tables + (`app`, `app_version`, `computed_surface` — space column, content-hash + identity, `current` pointer) in coreTables + cloud mirror + migration. + Ported rework tests come along (authoring-boundary, tool-groups, + publish). +- **3.2** Swap the apps plugin's internals onto the new model behind the + same costume: resolveTools/projectToolSchema/invokeTool read the new + tables; publish endpoint (`POST` source files); setCurrent/rollback + + versions listing routes; git/local-dir sync becomes fetch→publish. + Tool addresses and slug guards preserved; old collections + (`apps_descriptors`/`apps_tools`/`apps_sources`) still written by + NOTHING — one-off migration converts existing sources (re-fetch from + git), then the collections are dead. e2e: full existing custom-tools + suite green + new version-history/rollback scenario. +- **3.3** Console UI for the new model (sources → apps with versions, + publish status, rollback) replacing the ~1.3k LoC old client/panels. +- **3.4** Generators: fromOpenAPI + fromMCP + GeneratorClaim verification + + render endpoint, surfaced as the "add from spec/server" on-ramp. + (Vendors google/microsoft conversion ~1.15k LoC ports later with 0.2.) + +## Spaces: the rework's tenancy primitive (added Rhys, 2026-07-28) + +The rework's SDK has exactly one tenancy concept: `SpaceId` +(`.reference/executor-rework/docs/TENANCY.md`, `docs/MODEL.md`). Adopt it as +the end-state tenancy model here. The shape: + +- **Flat, no hierarchy, no default.** No parent, no inheritance, no + distinguished rows (rework D39/D44b). Sharing is an explicit act, never a + tree walk (R2 — re-ruled twice, do not relitigate). Every scoped row + carries a `space` column; every space-scoped route carries the space in + the URL path, never ambient context. +- **No org/project/team/user below the deployment edge.** The engine answers + "what is in this space"; the deployment answers "who is this and which + spaces may they touch" through named seams (`authorizeBrowserResource`, + `dashboardSpacesForPrincipal`, MCP space resolver). Membership is + authorized LIVE per request at the edge — projection may be cached, + authorization never. +- **Deployment mappings:** cloud = one WorkOS org mirrors lazily to one + space (mirror row `workosOrgId → space` is also the billing join — Autumn + keys on workosOrgId, never a space); self-host = one instance, one + found-or-created "Default" space, Better Auth org repurposed as the + instance membership table; local/desktop = one "local" space, one static + principal, single-tenancy declared via explicit sentinel, not defaulted. +- **Spaces curate, principals own.** Custody (secrets, connections' + secret-bearing halves) belongs to principals via opaque `OwnerRef` + + per-request `Reach` (R3); spaces curate apps and hold rows. Publishing an + app into a space IS its presence there — no installation rows, no + bindings; cross-space arrival (sharing/registry) is a future share-edge + row, not golden-path ceremony (D45/D42). +- Cross-org OAuth callbacks complete into the _initiating_ org's space; the + org header is a routing hint, not an authorization statement (R7). + +**DECIDED (Rhys, 2026-07-28): spaces land in the CORE PRODUCT NOW — Phase S, +before the apps port.** Apps then publishes into real spaces from birth. + +**Mapping onto this repo — the no-migration trick:** today's `tenant` column +is de facto the space — cloud writes the WorkOS org id there, self-host/local +write one constant. So: keep the persisted `tenant` column name (same rule as +`plugin_id`), and declare that **its value IS the space id**. Each org's +first space is grandfathered with `spaceId = ` (the +org id); NEW spaces mint fresh ids written into the same column. The org→ +space mirror table records which space ids belong to which org (and carries +the billing join — Autumn keys on workosOrgId, never a space). Existing rows +never move; multi-space needs no schema change on scoped tables. "Space" is +contract vocabulary; "tenant" retreats to cloud-internal (see Vocabulary). + +### Phase S — spaces in core (before apps) + +- **S1 — space table + boot ensure (no readers).** Greenfield `space` table + (id, name, created_at) in coreTables + cloud mirror + migration, plus the + cloud `org→space` mirror table. Boot/first-contact ensures the degenerate + space per deployment: cloud lazily on first authenticated org contact + (grandfathered id = org id), self-host "Default", local "local". Same + no-readers pattern as PR 1.1. +- **S2 — space threading.** `SpaceId` brand in the SDK; the scoped-executor + seam resolves request → space id (today: identical to tenant) and stamps + it; reads interpret `tenant` columns as space ids. No behavior change — + this is the retype + vocabulary PR. `reach: "tenant"` reads as + "space-wide reach" from here on. +- **S3 — multi-space on cloud + product surface.** Create/rename space, + space switcher in the console, membership = org membership (all spaces of + the org for now), org→N mirror rows, space-scoped catalog/connection + views (rows follow automatically — they're keyed by the space id in the + tenant column). Billing stays org-keyed via the mirror. e2e: two spaces + in one org, isolation of catalogs/connections between them, switcher. +- **S4 — sharing model on spaces.** Connect-time "share with space" writes + the space's shared owner-ref (replaces the org tier semantically; the + persisted `owner: "org"|"user"` column keeps its name, read-side + reinterpretation only). Edge computes reach as + [principal, sharedRef(space) for each member space]. This is the bridge + to Phase 4's full owner-set reach. +- **Later (not scheduled): local multi-space, file-backed.** Each local + space is its own store file (portable — copy/backup/sync/check in + alongside a repo); the daemon opens one storage handle per space and + routes by space id; the engine still sees flat SpaceIds. Custody never + enters the space file — secrets stay in the principal-keyed provider + (keychain), so a copied file carries connection metadata, never tokens. +- Phase 4 completes the picture: space-in-path routes, + `dashboardSpacesForPrincipal`-style seams, reach as an explicit owner + set. + +Apps (Phase 3) then lands after S1/S2 — app rows carry a real `space` FK +from birth; S3/S4 can proceed in parallel with the apps port. + +Interaction with shipped Phase 1 work: the subject table and platform view +are edge/deployment concepts (who exists in this identity world, what may +an operator read) — they sit above spaces and survive unchanged; `reach: +"tenant"` becomes "space-wide reach" vocabulary-wise at Phase 4. + +## Vocabulary: retire "tenant" from consumer-facing surfaces (Rhys, 2026-07-27) + +Direction: the SDK should be agnostic to the consumer's data-model shape, the +way Composio's is — consumers pass an opaque user/owner id string and hang +whatever hierarchy they have (user, team, workspace) off it; the SDK never +models their org chart. The rework already designed this (`OwnerRef` opaque +brand, `Reach = {owners}`, deployments translate identity at the edge) — adopt +its vocabulary, not its schedule. "Tenant" remains a real _internal_ concept +(which Executor-cloud org owns the rows) but must not leak into surfaces +consumers touch. + +Rules, effective immediately: + +- Persisted column names (`tenant`, `subject`, `owner`) stay — renaming + storage is a cross-host migration for zero behavior (same rule as + `plugin_id`). +- Internal policy code (`reach: "tenant"`, `ExecutorOwnerPolicyContext`) may + keep the words. +- One contract noun: **`owner`** — the opaque ref naming who a connection + belongs to (end-user, team, workspace, or the org itself; the rework's + `OwnerRef`). "User" cannot be the contract noun because org-shared + connections are a load-bearing feature an end-user word can't cover. +- **"user" is presentation vocabulary only**, for surfaces whose rows genuinely + are the customer's end-users — the Phase 1.5 admin API says `users` because + the subject table is populated from authenticated principals. +- Hazard: the persisted `owner` column keeps its old meaning (the + `"org" | "user"` tier discriminator), while Phase 4 introduces the opaque + ref as a distinctly named type (`OwnerRef`). Same word, two meanings, + separated by layer — say so in policy-layer comments. +- The full swap (opaque owner refs at the SDK boundary, tenant retreating to a + cloud-internal detail) lands with the Phase 4 minimal SDK. + +## Phase 4 — minimal SDK + platform/product split + +New consumer boundary modeled on the rework: `createExecutor` / +`createRemoteExecutor`, no Effect at the boundary, `tools.list/execute` + +connections + the new users/admin surface; policies/toolkits become optional +layers consumers opt into, not baked opinions (Composio-style minimalism). + +Authorization end-state (Rhys, 2026-07-27): the engine enforces scope +mechanically but never decides policy — the host computes, per request, the +set of owners the request may read (the rework's `Reach = { owners }`), and +the storage predicate is just `row.owner ∈ reach.owners`. The 1.3 +`reach: "bound" | "tenant"` enum is a stepping stone and is replaced by this +set shape here: "bound" = a reach of one, "tenant" = all owners (wildcard so +cloud needn't enumerate), and in-between cases (team views, support sessions, +subset-restricted org keys) are just different sets. Writes stay hard-pinned +to a single owner; only read reach generalizes. Trust model becomes a +per-deployment choice: a trusted backend with a server key gets tenant-wide +reach and layers its own authorization (Composio-style); an end-user session +gets a reach of one. The enum-to-set swap is contained: the policy context is +constructed in exactly two places. +Start the platform/product package separation _during_ 2.3–2.6 (each PR moves +one thing to its final home) rather than as a big bang; the SDK boundary +itself lands last. + +## Bookkeeping + +- Create `MISTAKES.md` / `DESIRES.md` / `LEARNINGS.md` (required by AGENTS.md, + currently missing) with the first entries from this migration. +- `apps/local/src/db/executor-schema.ts` is already stale vs `coreTables` + (missing several columns); its "keep in sync" comment is untrustworthy — + runtime schema comes from the ensure path, not this file.