Unified · Split
| @@ -1,9 +0,0 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/plugin-mcp": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Stdio MCP servers are kept alive between tool calls** | ||
| 6 | |||
| 7 | Every tool call on a stdio MCP integration used to spawn a fresh child process, run the full MCP handshake, call the one tool, and tear the child down — roughly a second of overhead per call for an `npx`-launched server, on every call. Remote and app-server connections already reused sessions through the connection pool; plain stdio now joins them, with the same five-minute idle window, the same hashed identity key (command, args, cwd, secret env, credential values, owner and connection all separate identities), and the same drop-on-transport-failure semantics. This matches how MCP clients drive stdio servers generally: one long-lived child per session, not one per call. | ||
| 8 | |||
| 9 | A server that genuinely depends on fresh-process semantics can opt out with `spawnPerCall: true` in its stdio config (also accepted by the add-server API). The Codex app-server bridge ignores the opt-out — its approvals are session state, so it must pool. | ||
| @@ -40,6 +40,34 @@ develop on its `main`, publish a bump, then bump the dependency here. The · 39 unmodified lines | |||
| 40 | The e2e globalsetup files are the source of truth for "how do I boot a | 40 | The e2e globalsetup files are the source of truth for "how do I boot a |
| 41 | working instance of X" — read them before inventing a boot path. | 41 | working instance of X" — read them before inventing a boot path. |
| 42 | 42 | ||
| 43 | A desktop dev run collides with an INSTALLED Executor in three places, all of | ||
| 44 | which look like something else. Give the dev run its own of each: | ||
| 45 | |||
| 46 | ``` | ||
| 47 | EXECUTOR_DESKTOP_USER_DATA=/tmp/executor-desktop-dev-userdata \ | ||
| 48 | EXECUTOR_DESKTOP_SCOPE_DIR=/tmp/executor-desktop-dev-scope \ | ||
| 49 | EXECUTOR_DESKTOP_SETTINGS_DIR=/tmp/executor-desktop-dev-settings \ | ||
| 50 | bun run dev | ||
| 51 | ``` | ||
| 52 | |||
| 53 | - **userData** holds Electron's single-instance lock, so the second process | ||
| 54 | quits at startup with **exit code 0 and no message** — the log simply stops | ||
| 55 | after "starting electron app". | ||
| 56 | - **The scope dir** (`~/.executor`) holds the sidecar's SQLite, owned by | ||
| 57 | whoever opened it first; the app reports "Failed to open local SQLite data". | ||
| 58 | - **The port** comes from `settings.json` in the settings dir; write | ||
| 59 | `{"server":{"port":<free>}}` there before the first launch. | ||
| 60 | |||
| 61 | Do NOT move these by pointing `HOME` at a scratch directory. The plugins a | ||
| 62 | local run drives resolve their own paths from the real home, and a synthetic | ||
| 63 | one breaks them in ways that read as product bugs: Codex Computer Use fails | ||
| 64 | every call with "Sky Computer Use native pipe startup failed", even with | ||
| 65 | `.codex` symlinked back. | ||
| 66 | |||
| 67 | Renderer edits inside a workspace package can be served from vite's dep | ||
| 68 | cache rather than the source the package exports. If a change does not appear | ||
| 69 | after a reload, delete `apps/desktop/node_modules/.vite` and restart. | ||
| 70 | |||
| 43 | ## E2E: running, viewing, sharing | 71 | ## E2E: running, viewing, sharing |
| 44 | 72 | ||
| 45 | `e2e/AGENTS.md` covers writing scenarios. Operationally: | 73 | `e2e/AGENTS.md` covers writing scenarios. Operationally: |
| @@ -1,5 +1,50 @@ | |||
| 1 | # executor | 1 | # executor |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - [#1869](https://github.com/UsefulSoftwareCo/executor/pull/1869) [`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Explain macOS permissions for Codex plugins instead of failing with an opaque | ||
| 8 | error. A refused grant used to surface as `Internal tool error [id]` — the | ||
| 9 | plugin reports "Unknown error" and only a numeric code says what happened, so | ||
| 10 | neither the user nor the model could tell that macOS was the blocker. | ||
| 11 | |||
| 12 | The bridge now recognises those codes and answers with the grant to enable and | ||
| 13 | where to find it. Each plugin's add screen also states what macOS will ask for | ||
| 14 | before anything runs, with a link straight to the right Privacy pane — macOS | ||
| 15 | asks once, and a dismissed prompt never returns. | ||
| 16 | |||
| 17 | The add screen checks that access when it opens, and holds the Add button | ||
| 18 | until the plugin answers. Adding one that macOS is still blocking produced an | ||
| 19 | integration that looked connected and failed on its first call, by which point | ||
| 20 | the screen explaining the fix was gone. | ||
| 21 | |||
| 22 | - Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: | ||
| 23 | - @executor-js/local@1.6.6 | ||
| 24 | - @executor-js/api@1.4.69 | ||
| 25 | - @executor-js/sdk@1.6.6 | ||
| 26 | - @executor-js/runtime-quickjs@1.6.6 | ||
| 27 | |||
| 28 | ## 1.6.5 | ||
| 29 | |||
| 30 | ### Patch Changes | ||
| 31 | |||
| 32 | - [#1863](https://github.com/UsefulSoftwareCo/executor/pull/1863) [`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Adding a Codex plugin no longer asks for anything. `CODEX_HOME` is a path the | ||
| 33 | scanner already resolved, but it was passed on the channel that makes an | ||
| 34 | environment variable a credential — so the integration declared it as one, and | ||
| 35 | a person who reached the connect step was shown a masked field for a value | ||
| 36 | they should never have to know. | ||
| 37 | |||
| 38 | Stdio integrations can now carry non-secret environment as static | ||
| 39 | configuration, separate from declared secrets. The Codex plugins use it: they | ||
| 40 | declare no auth, and their connection is created for them. | ||
| 41 | |||
| 42 | - Updated dependencies []: | ||
| 43 | - @executor-js/local@1.6.5 | ||
| 44 | - @executor-js/sdk@1.6.5 | ||
| 45 | - @executor-js/runtime-quickjs@1.6.5 | ||
| 46 | - @executor-js/api@1.4.68 | ||
| 47 | |||
| 3 | ## 1.6.4 | 48 | ## 1.6.4 |
| 4 | 49 | ||
| 5 | ### Patch Changes | 50 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "executor", | 2 | "name": "executor", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "bin": { | 5 | "bin": { |
| 6 | "executor": "./bin/executor.ts" | 6 | "executor": "./bin/executor.ts" |
| @@ -1,5 +1,53 @@ | |||
| 1 | # @executor-js/cloud | 1 | # @executor-js/cloud |
| 2 | 2 | ||
| 3 | ## 1.4.67 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - [#1864](https://github.com/UsefulSoftwareCo/executor/pull/1864) [`fad3650`](https://github.com/UsefulSoftwareCo/executor/commit/fad36504439a07e6080beba243b24b73cd1b9741) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **API key validation is cached per isolate** | ||
| 8 | |||
| 9 | Every MCP request and every API-key-authenticated `/api/*` request used to pay a live WorkOS round trip (~100-150ms) to validate the presented key, on every single request. The JWT bearer path beside it already verified locally against a JWKS cached for an hour; API keys had no cache at all. | ||
| 10 | |||
| 11 | Successful validations are now cached in a bounded per-isolate map for 60 seconds, keyed by the SHA-256 digest of the key value (never the raw credential). The MCP handler used to rebuild its whole auth layer (and with it the cache) on every request; it now builds the layer once per isolate, so the cache holds on both the `/api/*` and `/mcp` planes. Invalid keys and upstream failures are never cached, so probing bad keys cannot pollute the map and a freshly created key works immediately. The tradeoff: a revoked key remains usable for up to 60 seconds within an isolate that validated it before revocation — far tighter than the one-hour rotation window the JWT path already accepts. | ||
| 12 | |||
| 13 | - Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed), [`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d), [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: | ||
| 14 | - @executor-js/plugin-mcp@1.6.6 | ||
| 15 | - @executor-js/execution@1.6.6 | ||
| 16 | - @executor-js/react@1.4.69 | ||
| 17 | - @executor-js/api@1.4.69 | ||
| 18 | - @executor-js/cloudflare@0.0.48 | ||
| 19 | - @executor-js/host-mcp@1.4.4 | ||
| 20 | - @executor-js/mcp-apps-shell@1.4.17 | ||
| 21 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 22 | - @executor-js/plugin-graphql@1.6.6 | ||
| 23 | - @executor-js/plugin-openapi@1.6.6 | ||
| 24 | - @executor-js/plugin-toolkits@1.5.41 | ||
| 25 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 26 | - @executor-js/sdk@1.6.6 | ||
| 27 | - @executor-js/runtime-quickjs@1.6.6 | ||
| 28 | - @executor-js/vite-plugin@0.0.66 | ||
| 29 | |||
| 30 | ## 1.4.66 | ||
| 31 | |||
| 32 | ### Patch Changes | ||
| 33 | |||
| 34 | - Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]: | ||
| 35 | - @executor-js/plugin-mcp@1.6.5 | ||
| 36 | - @executor-js/sdk@1.6.5 | ||
| 37 | - @executor-js/runtime-quickjs@1.6.5 | ||
| 38 | - @executor-js/execution@1.6.5 | ||
| 39 | - @executor-js/plugin-graphql@1.6.5 | ||
| 40 | - @executor-js/plugin-openapi@1.6.5 | ||
| 41 | - @executor-js/api@1.4.68 | ||
| 42 | - @executor-js/vite-plugin@0.0.65 | ||
| 43 | - @executor-js/cloudflare@0.0.47 | ||
| 44 | - @executor-js/host-mcp@1.4.4 | ||
| 45 | - @executor-js/mcp-apps-shell@1.4.16 | ||
| 46 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 47 | - @executor-js/plugin-toolkits@1.5.40 | ||
| 48 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 49 | - @executor-js/react@1.4.68 | ||
| 50 | |||
| 3 | ## 1.4.65 | 51 | ## 1.4.65 |
| 4 | 52 | ||
| 5 | ### Patch Changes | 53 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/cloud", | 2 | "name": "@executor-js/cloud", |
| 3 | "version": "1.4.65", | 3 | "version": "1.4.67", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "scripts": { | 6 | "scripts": { |
| @@ -24,6 +24,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteIm · 23 unmodified lines | |||
| 24 | import { Route as ApiKeysRouteImport } from './routes/app/api-keys' | 24 | import { Route as ApiKeysRouteImport } from './routes/app/api-keys' |
| 25 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' | 25 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' |
| 26 | import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' | 26 | import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' |
| 27 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' | ||
| 27 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' | 28 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 28 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' | 29 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' |
| 29 | import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' | 30 | import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' |
| @@ -115,6 +116,14 @@ const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({ · 85 unmodified lines | |||
| 115 | path: '/{-$orgSlug}/resume/$executionId', | 116 | path: '/{-$orgSlug}/resume/$executionId', |
| 116 | getParentRoute: () => rootRouteImport, | 117 | getParentRoute: () => rootRouteImport, |
| 117 | } as any) | 118 | } as any) |
| 119 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute = | ||
| 120 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update( | ||
| 121 | { | ||
| 122 | id: '/{-$orgSlug}/integrations/browse', | ||
| 123 | path: '/{-$orgSlug}/integrations/browse', | ||
| 124 | getParentRoute: () => rootRouteImport, | ||
| 125 | } as any, | ||
| 126 | ) | ||
| 118 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = | 127 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = |
| 119 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( | 128 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( |
| 120 | { | 129 | { |
| @@ -172,6 +181,7 @@ export interface FileRoutesByFullPath { · 51 unmodified lines | |||
| 172 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute | 181 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 173 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 182 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 174 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 183 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 184 | '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 175 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute | 185 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 176 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | 186 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 177 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 187 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -194,6 +204,7 @@ export interface FileRoutesByTo { · 16 unmodified lines | |||
| 194 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute | 204 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 195 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 205 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 196 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 206 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 207 | '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 197 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute | 208 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 198 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | 209 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 199 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 210 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -217,6 +228,7 @@ export interface FileRoutesById { · 17 unmodified lines | |||
| 217 | '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute | 228 | '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute |
| 218 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 229 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 219 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 230 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 231 | '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 220 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute | 232 | '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute |
| 221 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | 233 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 222 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 234 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -241,6 +253,7 @@ export interface FileRouteTypes { · 18 unmodified lines | |||
| 241 | | '/{-$orgSlug}/billing/plans' | 253 | | '/{-$orgSlug}/billing/plans' |
| 242 | | '/{-$orgSlug}/connect/$integrationSlug' | 254 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 243 | | '/{-$orgSlug}/integrations/$namespace' | 255 | | '/{-$orgSlug}/integrations/$namespace' |
| 256 | | '/{-$orgSlug}/integrations/browse' | ||
| 244 | | '/{-$orgSlug}/resume/$executionId' | 257 | | '/{-$orgSlug}/resume/$executionId' |
| 245 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 258 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 246 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 259 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -263,6 +276,7 @@ export interface FileRouteTypes { · 16 unmodified lines | |||
| 263 | | '/{-$orgSlug}/billing/plans' | 276 | | '/{-$orgSlug}/billing/plans' |
| 264 | | '/{-$orgSlug}/connect/$integrationSlug' | 277 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 265 | | '/{-$orgSlug}/integrations/$namespace' | 278 | | '/{-$orgSlug}/integrations/$namespace' |
| 279 | | '/{-$orgSlug}/integrations/browse' | ||
| 266 | | '/{-$orgSlug}/resume/$executionId' | 280 | | '/{-$orgSlug}/resume/$executionId' |
| 267 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 281 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 268 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 282 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -285,6 +299,7 @@ export interface FileRouteTypes { · 16 unmodified lines | |||
| 285 | | '/{-$orgSlug}/billing_/plans' | 299 | | '/{-$orgSlug}/billing_/plans' |
| 286 | | '/{-$orgSlug}/connect/$integrationSlug' | 300 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 287 | | '/{-$orgSlug}/integrations/$namespace' | 301 | | '/{-$orgSlug}/integrations/$namespace' |
| 302 | | '/{-$orgSlug}/integrations/browse' | ||
| 288 | | '/{-$orgSlug}/resume/$executionId' | 303 | | '/{-$orgSlug}/resume/$executionId' |
| 289 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 304 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 290 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 305 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -307,6 +322,7 @@ export interface RootRouteChildren { · 16 unmodified lines | |||
| 307 | Billing_DotplansRoute: typeof Billing_DotplansRoute | 322 | Billing_DotplansRoute: typeof Billing_DotplansRoute |
| 308 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 323 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 309 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 324 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 325 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 310 | ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute | 326 | ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute |
| 311 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 327 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 312 | } | 328 | } |
| @@ -418,6 +434,13 @@ declare module '@tanstack/react-router' { · 105 unmodified lines | |||
| 418 | preLoaderRoute: typeof ResumeDotexecutionIdRouteImport | 434 | preLoaderRoute: typeof ResumeDotexecutionIdRouteImport |
| 419 | parentRoute: typeof rootRouteImport | 435 | parentRoute: typeof rootRouteImport |
| 420 | } | 436 | } |
| 437 | '/{-$orgSlug}/integrations/browse': { | ||
| 438 | id: '/{-$orgSlug}/integrations/browse' | ||
| 439 | path: '/{-$orgSlug}/integrations/browse' | ||
| 440 | fullPath: '/{-$orgSlug}/integrations/browse' | ||
| 441 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport | ||
| 442 | parentRoute: typeof rootRouteImport | ||
| 443 | } | ||
| 421 | '/{-$orgSlug}/integrations/$namespace': { | 444 | '/{-$orgSlug}/integrations/$namespace': { |
| 422 | id: '/{-$orgSlug}/integrations/$namespace' | 445 | id: '/{-$orgSlug}/integrations/$namespace' |
| 423 | path: '/{-$orgSlug}/integrations/$namespace' | 446 | path: '/{-$orgSlug}/integrations/$namespace' |
| @@ -511,6 +534,8 @@ const rootRouteChildren: RootRouteChildren = { · 87 unmodified lines | |||
| 511 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, | 534 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, |
| 512 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: | 535 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 513 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, | 536 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 537 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: | ||
| 538 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute, | ||
| 514 | ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute, | 539 | ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute, |
| 515 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: | 540 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: |
| 516 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute, | 541 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute, |
| @@ -520,11 +545,15 @@ export const routeTree = rootRouteImport · 3 unmodified lines | |||
| 520 | ._addFileTypes<FileRouteTypes>() | 545 | ._addFileTypes<FileRouteTypes>() |
| 521 | 546 | ||
| 522 | import type { getRouter } from './router.tsx' | 547 | import type { getRouter } from './router.tsx' |
| 548 | |||
| 523 | import type { startInstance } from './start.ts' | 549 | import type { startInstance } from './start.ts' |
| 550 | |||
| 524 | declare module '@tanstack/react-start' { | 551 | declare module '@tanstack/react-start' { |
| 525 | interface Register { | 552 | interface Register { |
| 526 | ssr: true | 553 | ssr: true |
| 554 | |||
| 527 | router: Awaited<ReturnType<typeof getRouter>> | 555 | router: Awaited<ReturnType<typeof getRouter>> |
| 556 | |||
| 528 | config: Awaited<ReturnType<typeof startInstance.getOptions>> | 557 | config: Awaited<ReturnType<typeof startInstance.getOptions>> |
| 529 | } | 558 | } |
| 530 | } | 559 | } |
| @@ -1,5 +1,9 @@ | |||
| 1 | # @executor-js/desktop | 1 | # @executor-js/desktop |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ## 1.6.5 | ||
| 6 | |||
| 3 | ## 1.6.4 | 7 | ## 1.6.4 |
| 4 | 8 | ||
| 5 | ## 1.6.3 | 9 | ## 1.6.3 |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/desktop", | 2 | "name": "@executor-js/desktop", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "homepage": "https://github.com/UsefulSoftwareCo/executor", | 5 | "homepage": "https://github.com/UsefulSoftwareCo/executor", |
| 6 | "license": "MIT", | 6 | "license": "MIT", |
| @@ -78,7 +78,13 @@ import { · 77 unmodified lines | |||
| 78 | // executor.jsonc plugin manifest) is pinned separately to ~/.executor in | 78 | // executor.jsonc plugin manifest) is pinned separately to ~/.executor in |
| 79 | // main/sidecar.ts — that path matches the CLI's default. | 79 | // main/sidecar.ts — that path matches the CLI's default. |
| 80 | app.setName("Executor"); | 80 | app.setName("Executor"); |
| 81 | app.setPath("userData", join(app.getPath("appData"), "Executor")); | 81 | // A dev run must not collide with an installed Executor: userData also holds |
| 82 | // Electron's single-instance lock, so sharing it makes the second process quit | ||
| 83 | // silently at startup. `EXECUTOR_DESKTOP_USER_DATA` gives a dev run its own. | ||
| 84 | app.setPath( | ||
| 85 | "userData", | ||
| 86 | process.env.EXECUTOR_DESKTOP_USER_DATA ?? join(app.getPath("appData"), "Executor"), | ||
| 87 | ); | ||
| 82 | 88 | ||
| 83 | log.initialize({ preload: true }); | 89 | log.initialize({ preload: true }); |
| 84 | log.transports.file.level = "info"; | 90 | log.transports.file.level = "info"; |
| @@ -215,6 +215,18 @@ const resolveClientDir = (): string => { · 214 unmodified lines | |||
| 215 | const delay = (ms: number): Promise<void> => | 215 | const delay = (ms: number): Promise<void> => |
| 216 | new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); | 216 | new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); |
| 217 | 217 | ||
| 218 | /** | ||
| 219 | * Where this app keeps `data.db`, `auth.json`, and the server manifest. | ||
| 220 | * | ||
| 221 | * A dev run overrides it: that SQLite has ONE owner, so a dev build started | ||
| 222 | * beside an installed Executor otherwise dies on the installed app's lock. | ||
| 223 | * Redirecting HOME is not an alternative — the machine-local tools a plugin | ||
| 224 | * drives resolve their own paths from it, and Codex Computer Use fails at | ||
| 225 | * "native pipe startup" under a synthetic home. | ||
| 226 | */ | ||
| 227 | const executorScopeDir = (): string => | ||
| 228 | process.env.EXECUTOR_DESKTOP_SCOPE_DIR ?? join(homedir(), ".executor"); | ||
| 229 | |||
| 218 | export async function startSidecar(options: StartOptions = {}): Promise<SidecarConnection> { | 230 | export async function startSidecar(options: StartOptions = {}): Promise<SidecarConnection> { |
| 219 | const hostname = options.hostname ?? "127.0.0.1"; | 231 | const hostname = options.hostname ?? "127.0.0.1"; |
| 220 | const settings = getServerSettings(); | 232 | const settings = getServerSettings(); |
| @@ -226,7 +238,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 5 unmodified lines | |||
| 226 | // userData (set in main/index.ts) is still used for electron-store, | 238 | // userData (set in main/index.ts) is still used for electron-store, |
| 227 | // electron-log, and window-state — those stay app-scoped to avoid colliding | 239 | // electron-log, and window-state — those stay app-scoped to avoid colliding |
| 228 | // with anything else under HOME. | 240 | // with anything else under HOME. |
| 229 | const scopeDir = join(homedir(), ".executor"); | 241 | const scopeDir = executorScopeDir(); |
| 230 | const dataDir = scopeDir; | 242 | const dataDir = scopeDir; |
| 231 | mkdirSync(dataDir, { recursive: true }); | 243 | mkdirSync(dataDir, { recursive: true }); |
| 232 | 244 | ||
| @@ -429,7 +441,7 @@ const isDaemonReachable = async (origin: string): Promise<boolean> => { · 196 unmodified lines | |||
| 429 | * is handled by the existing single-instance / ownership logic. | 441 | * is handled by the existing single-instance / ownership logic. |
| 430 | */ | 442 | */ |
| 431 | export async function attachToSupervisedDaemon(): Promise<SidecarConnection | null> { | 443 | export async function attachToSupervisedDaemon(): Promise<SidecarConnection | null> { |
| 432 | const dataDir = join(homedir(), ".executor"); | 444 | const dataDir = executorScopeDir(); |
| 433 | const manifest = readManifest(dataDir); | 445 | const manifest = readManifest(dataDir); |
| 434 | const decision = await resolveSupervisedDaemonAttach(manifest, { | 446 | const decision = await resolveSupervisedDaemonAttach(manifest, { |
| 435 | isReachable: isDaemonReachable, | 447 | isReachable: isDaemonReachable, |
| @@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp · 16 unmodified lines | |||
| 17 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' | 17 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' |
| 18 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' | 18 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' |
| 19 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' | 19 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' |
| 20 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' | ||
| 20 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' | 21 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 21 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' | 22 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' |
| 22 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' | 23 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' |
| @@ -76,6 +77,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = · 53 unmodified lines | |||
| 76 | getParentRoute: () => rootRouteImport, | 77 | getParentRoute: () => rootRouteImport, |
| 77 | } as any, | 78 | } as any, |
| 78 | ) | 79 | ) |
| 80 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute = | ||
| 81 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update( | ||
| 82 | { | ||
| 83 | id: '/{-$orgSlug}/integrations/browse', | ||
| 84 | path: '/{-$orgSlug}/integrations/browse', | ||
| 85 | getParentRoute: () => rootRouteImport, | ||
| 86 | } as any, | ||
| 87 | ) | ||
| 79 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = | 88 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = |
| 80 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( | 89 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( |
| 81 | { | 90 | { |
| @@ -128,6 +137,7 @@ export interface FileRoutesByFullPath { · 46 unmodified lines | |||
| 128 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute | 137 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute |
| 129 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 138 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 130 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 139 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 140 | '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 131 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 141 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 132 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | 142 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 133 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 143 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -143,6 +153,7 @@ export interface FileRoutesByTo { · 9 unmodified lines | |||
| 143 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute | 153 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute |
| 144 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 154 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 145 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 155 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 156 | '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 146 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 157 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 147 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | 158 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 148 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 159 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -159,6 +170,7 @@ export interface FileRoutesById { · 10 unmodified lines | |||
| 159 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute | 170 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute |
| 160 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 171 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 161 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 172 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 173 | '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 162 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 174 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 163 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | 175 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 164 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 176 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -176,6 +188,7 @@ export interface FileRouteTypes { · 11 unmodified lines | |||
| 176 | | '/{-$orgSlug}/artifacts/$artifactId' | 188 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 177 | | '/{-$orgSlug}/connect/$integrationSlug' | 189 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 178 | | '/{-$orgSlug}/integrations/$namespace' | 190 | | '/{-$orgSlug}/integrations/$namespace' |
| 191 | | '/{-$orgSlug}/integrations/browse' | ||
| 179 | | '/{-$orgSlug}/resume/$executionId' | 192 | | '/{-$orgSlug}/resume/$executionId' |
| 180 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 193 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 181 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 194 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -191,6 +204,7 @@ export interface FileRouteTypes { · 9 unmodified lines | |||
| 191 | | '/{-$orgSlug}/artifacts/$artifactId' | 204 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 192 | | '/{-$orgSlug}/connect/$integrationSlug' | 205 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 193 | | '/{-$orgSlug}/integrations/$namespace' | 206 | | '/{-$orgSlug}/integrations/$namespace' |
| 207 | | '/{-$orgSlug}/integrations/browse' | ||
| 194 | | '/{-$orgSlug}/resume/$executionId' | 208 | | '/{-$orgSlug}/resume/$executionId' |
| 195 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 209 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 196 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 210 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -206,6 +220,7 @@ export interface FileRouteTypes { · 9 unmodified lines | |||
| 206 | | '/{-$orgSlug}/artifacts/$artifactId' | 220 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 207 | | '/{-$orgSlug}/connect/$integrationSlug' | 221 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 208 | | '/{-$orgSlug}/integrations/$namespace' | 222 | | '/{-$orgSlug}/integrations/$namespace' |
| 223 | | '/{-$orgSlug}/integrations/browse' | ||
| 209 | | '/{-$orgSlug}/resume/$executionId' | 224 | | '/{-$orgSlug}/resume/$executionId' |
| 210 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 225 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 211 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 226 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -221,6 +236,7 @@ export interface RootRouteChildren { · 9 unmodified lines | |||
| 221 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 236 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 222 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 237 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 223 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 238 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 239 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 224 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 240 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 225 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 241 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 226 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute | 242 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| @@ -284,6 +300,13 @@ declare module '@tanstack/react-router' { · 57 unmodified lines | |||
| 284 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport | 300 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport |
| 285 | parentRoute: typeof rootRouteImport | 301 | parentRoute: typeof rootRouteImport |
| 286 | } | 302 | } |
| 303 | '/{-$orgSlug}/integrations/browse': { | ||
| 304 | id: '/{-$orgSlug}/integrations/browse' | ||
| 305 | path: '/{-$orgSlug}/integrations/browse' | ||
| 306 | fullPath: '/{-$orgSlug}/integrations/browse' | ||
| 307 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport | ||
| 308 | parentRoute: typeof rootRouteImport | ||
| 309 | } | ||
| 287 | '/{-$orgSlug}/integrations/$namespace': { | 310 | '/{-$orgSlug}/integrations/$namespace': { |
| 288 | id: '/{-$orgSlug}/integrations/$namespace' | 311 | id: '/{-$orgSlug}/integrations/$namespace' |
| 289 | path: '/{-$orgSlug}/integrations/$namespace' | 312 | path: '/{-$orgSlug}/integrations/$namespace' |
| @@ -369,6 +392,8 @@ const rootRouteChildren: RootRouteChildren = { · 79 unmodified lines | |||
| 369 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, | 392 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, |
| 370 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: | 393 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 371 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, | 394 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 395 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: | ||
| 396 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute, | ||
| 372 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: | 397 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: |
| 373 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute, | 398 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute, |
| 374 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: | 399 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: |
| @@ -1,5 +1,47 @@ | |||
| 1 | # @executor-js/host-selfhost | 1 | # @executor-js/host-selfhost |
| 2 | 2 | ||
| 3 | ## 0.0.48 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed), [`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d), [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: | ||
| 8 | - @executor-js/plugin-mcp@1.6.6 | ||
| 9 | - @executor-js/execution@1.6.6 | ||
| 10 | - @executor-js/react@1.4.69 | ||
| 11 | - @executor-js/analytics@0.1.13 | ||
| 12 | - @executor-js/api@1.4.69 | ||
| 13 | - @executor-js/host-mcp@1.4.4 | ||
| 14 | - @executor-js/mcp-apps-shell@1.4.17 | ||
| 15 | - @executor-js/app@1.4.4 | ||
| 16 | - @executor-js/plugin-graphql@1.6.6 | ||
| 17 | - @executor-js/plugin-openapi@1.6.6 | ||
| 18 | - @executor-js/plugin-toolkits@1.5.41 | ||
| 19 | - @executor-js/plugin-provider-service-split@0.0.20 | ||
| 20 | - @executor-js/sdk@1.6.6 | ||
| 21 | - @executor-js/runtime-quickjs@1.6.6 | ||
| 22 | - @executor-js/plugin-encrypted-secrets@0.0.48 | ||
| 23 | |||
| 24 | ## 0.0.47 | ||
| 25 | |||
| 26 | ### Patch Changes | ||
| 27 | |||
| 28 | - Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]: | ||
| 29 | - @executor-js/plugin-mcp@1.6.5 | ||
| 30 | - @executor-js/sdk@1.6.5 | ||
| 31 | - @executor-js/runtime-quickjs@1.6.5 | ||
| 32 | - @executor-js/execution@1.6.5 | ||
| 33 | - @executor-js/plugin-graphql@1.6.5 | ||
| 34 | - @executor-js/plugin-openapi@1.6.5 | ||
| 35 | - @executor-js/app@1.4.4 | ||
| 36 | - @executor-js/analytics@0.1.12 | ||
| 37 | - @executor-js/api@1.4.68 | ||
| 38 | - @executor-js/host-mcp@1.4.4 | ||
| 39 | - @executor-js/mcp-apps-shell@1.4.16 | ||
| 40 | - @executor-js/plugin-encrypted-secrets@0.0.47 | ||
| 41 | - @executor-js/plugin-provider-service-split@0.0.19 | ||
| 42 | - @executor-js/plugin-toolkits@1.5.40 | ||
| 43 | - @executor-js/react@1.4.68 | ||
| 44 | |||
| 3 | ## 0.0.46 | 45 | ## 0.0.46 |
| 4 | 46 | ||
| 5 | ### Patch Changes | 47 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/host-selfhost", | 2 | "name": "@executor-js/host-selfhost", |
| 3 | "version": "0.0.46", | 3 | "version": "0.0.48", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "exports": { | 6 | "exports": { |
| @@ -21,6 +21,7 @@ import { Route as AdminRouteImport } from './routes/app/admin' · 20 unmodified lines | |||
| 21 | import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' | 21 | import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' |
| 22 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' | 22 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' |
| 23 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' | 23 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' |
| 24 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' | ||
| 24 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' | 25 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' |
| 25 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' | 26 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' |
| 26 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' | 27 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' |
| @@ -101,6 +102,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = · 74 unmodified lines | |||
| 101 | getParentRoute: () => rootRouteImport, | 102 | getParentRoute: () => rootRouteImport, |
| 102 | } as any, | 103 | } as any, |
| 103 | ) | 104 | ) |
| 105 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute = | ||
| 106 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update( | ||
| 107 | { | ||
| 108 | id: '/{-$orgSlug}/integrations/browse', | ||
| 109 | path: '/{-$orgSlug}/integrations/browse', | ||
| 110 | getParentRoute: () => rootRouteImport, | ||
| 111 | } as any, | ||
| 112 | ) | ||
| 104 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = | 113 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = |
| 105 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( | 114 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( |
| 106 | { | 115 | { |
| @@ -157,6 +166,7 @@ export interface FileRoutesByFullPath { · 50 unmodified lines | |||
| 157 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute | 166 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute |
| 158 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 167 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 159 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 168 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 169 | '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 160 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 170 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 161 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | 171 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 162 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 172 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -176,6 +186,7 @@ export interface FileRoutesByTo { · 13 unmodified lines | |||
| 176 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute | 186 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute |
| 177 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 187 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 178 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 188 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 189 | '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 179 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 190 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 180 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | 191 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 181 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 192 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -196,6 +207,7 @@ export interface FileRoutesById { · 14 unmodified lines | |||
| 196 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute | 207 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute |
| 197 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 208 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 198 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 209 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 210 | '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 199 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 211 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 200 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute | 212 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 201 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 213 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -217,6 +229,7 @@ export interface FileRouteTypes { · 15 unmodified lines | |||
| 217 | | '/{-$orgSlug}/artifacts/$artifactId' | 229 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 218 | | '/{-$orgSlug}/connect/$integrationSlug' | 230 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 219 | | '/{-$orgSlug}/integrations/$namespace' | 231 | | '/{-$orgSlug}/integrations/$namespace' |
| 232 | | '/{-$orgSlug}/integrations/browse' | ||
| 220 | | '/{-$orgSlug}/resume/$executionId' | 233 | | '/{-$orgSlug}/resume/$executionId' |
| 221 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 234 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 222 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 235 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -236,6 +249,7 @@ export interface FileRouteTypes { · 13 unmodified lines | |||
| 236 | | '/{-$orgSlug}/artifacts/$artifactId' | 249 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 237 | | '/{-$orgSlug}/connect/$integrationSlug' | 250 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 238 | | '/{-$orgSlug}/integrations/$namespace' | 251 | | '/{-$orgSlug}/integrations/$namespace' |
| 252 | | '/{-$orgSlug}/integrations/browse' | ||
| 239 | | '/{-$orgSlug}/resume/$executionId' | 253 | | '/{-$orgSlug}/resume/$executionId' |
| 240 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 254 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 241 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 255 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -255,6 +269,7 @@ export interface FileRouteTypes { · 13 unmodified lines | |||
| 255 | | '/{-$orgSlug}/artifacts/$artifactId' | 269 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 256 | | '/{-$orgSlug}/connect/$integrationSlug' | 270 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 257 | | '/{-$orgSlug}/integrations/$namespace' | 271 | | '/{-$orgSlug}/integrations/$namespace' |
| 272 | | '/{-$orgSlug}/integrations/browse' | ||
| 258 | | '/{-$orgSlug}/resume/$executionId' | 273 | | '/{-$orgSlug}/resume/$executionId' |
| 259 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 274 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 260 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 275 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -274,6 +289,7 @@ export interface RootRouteChildren { · 13 unmodified lines | |||
| 274 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 289 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 275 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 290 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 276 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 291 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| 292 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 277 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute | 293 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute |
| 278 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 294 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 279 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute | 295 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| @@ -365,6 +381,13 @@ declare module '@tanstack/react-router' { · 85 unmodified lines | |||
| 365 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport | 381 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport |
| 366 | parentRoute: typeof rootRouteImport | 382 | parentRoute: typeof rootRouteImport |
| 367 | } | 383 | } |
| 384 | '/{-$orgSlug}/integrations/browse': { | ||
| 385 | id: '/{-$orgSlug}/integrations/browse' | ||
| 386 | path: '/{-$orgSlug}/integrations/browse' | ||
| 387 | fullPath: '/{-$orgSlug}/integrations/browse' | ||
| 388 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport | ||
| 389 | parentRoute: typeof rootRouteImport | ||
| 390 | } | ||
| 368 | '/{-$orgSlug}/integrations/$namespace': { | 391 | '/{-$orgSlug}/integrations/$namespace': { |
| 369 | id: '/{-$orgSlug}/integrations/$namespace' | 392 | id: '/{-$orgSlug}/integrations/$namespace' |
| 370 | path: '/{-$orgSlug}/integrations/$namespace' | 393 | path: '/{-$orgSlug}/integrations/$namespace' |
| @@ -455,6 +478,8 @@ const rootRouteChildren: RootRouteChildren = { · 84 unmodified lines | |||
| 455 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, | 478 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, |
| 456 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: | 479 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 457 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, | 480 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 481 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: | ||
| 482 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute, | ||
| 458 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: | 483 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: |
| 459 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute, | 484 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute, |
| 460 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: | 485 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: |
| @@ -1,5 +1,65 @@ | |||
| 1 | # @executor-js/local | 1 | # @executor-js/local |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - [#1865](https://github.com/UsefulSoftwareCo/executor/pull/1865) [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Desktop OAuth connects finish the moment the provider redirects** | ||
| 8 | |||
| 9 | When the desktop app runs an OAuth flow in the system browser, the app learned about completion by polling the local server once a second. The completed result sat in memory while the user watched the "Connecting…" spinner for up to a second more — about half a second wasted on average, on every connect. | ||
| 10 | |||
| 11 | The await endpoint now long-polls: the server holds the request open (up to 25 seconds per hold) and answers the instant the flow completes. The client polls one request at a time and reconnects after each answer, so requests never stack. Mixed versions stay compatible in both directions: an old client still gets its answer within one poll of a new server, and a new client against an old server behaves exactly as before. | ||
| 12 | |||
| 13 | - Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed), [`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d), [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: | ||
| 14 | - @executor-js/plugin-mcp@1.6.6 | ||
| 15 | - @executor-js/execution@1.6.6 | ||
| 16 | - @executor-js/react@1.4.69 | ||
| 17 | - @executor-js/analytics@0.1.13 | ||
| 18 | - @executor-js/api@1.4.69 | ||
| 19 | - @executor-js/host-mcp@1.4.4 | ||
| 20 | - @executor-js/mcp-apps-shell@1.4.17 | ||
| 21 | - @executor-js/app@1.4.4 | ||
| 22 | - @executor-js/plugin-graphql@1.6.6 | ||
| 23 | - @executor-js/plugin-onepassword@1.6.6 | ||
| 24 | - @executor-js/plugin-openapi@1.6.6 | ||
| 25 | - @executor-js/plugin-toolkits@1.5.41 | ||
| 26 | - @executor-js/plugin-provider-service-split@0.0.20 | ||
| 27 | - @executor-js/sdk@1.6.6 | ||
| 28 | - @executor-js/runtime-quickjs@1.6.6 | ||
| 29 | - @executor-js/config@1.6.6 | ||
| 30 | - @executor-js/plugin-file-secrets@1.6.6 | ||
| 31 | - @executor-js/plugin-keychain@1.6.6 | ||
| 32 | - @executor-js/plugin-example@1.6.6 | ||
| 33 | - @executor-js/plugin-desktop-settings@1.6.6 | ||
| 34 | - @executor-js/vite-plugin@0.0.66 | ||
| 35 | |||
| 36 | ## 1.6.5 | ||
| 37 | |||
| 38 | ### Patch Changes | ||
| 39 | |||
| 40 | - Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]: | ||
| 41 | - @executor-js/plugin-mcp@1.6.5 | ||
| 42 | - @executor-js/sdk@1.6.5 | ||
| 43 | - @executor-js/runtime-quickjs@1.6.5 | ||
| 44 | - @executor-js/execution@1.6.5 | ||
| 45 | - @executor-js/config@1.6.5 | ||
| 46 | - @executor-js/plugin-file-secrets@1.6.5 | ||
| 47 | - @executor-js/plugin-graphql@1.6.5 | ||
| 48 | - @executor-js/plugin-keychain@1.6.5 | ||
| 49 | - @executor-js/plugin-onepassword@1.6.5 | ||
| 50 | - @executor-js/plugin-openapi@1.6.5 | ||
| 51 | - @executor-js/plugin-example@1.6.5 | ||
| 52 | - @executor-js/plugin-desktop-settings@1.6.5 | ||
| 53 | - @executor-js/app@1.4.4 | ||
| 54 | - @executor-js/analytics@0.1.12 | ||
| 55 | - @executor-js/api@1.4.68 | ||
| 56 | - @executor-js/vite-plugin@0.0.65 | ||
| 57 | - @executor-js/host-mcp@1.4.4 | ||
| 58 | - @executor-js/mcp-apps-shell@1.4.16 | ||
| 59 | - @executor-js/plugin-provider-service-split@0.0.19 | ||
| 60 | - @executor-js/plugin-toolkits@1.5.40 | ||
| 61 | - @executor-js/react@1.4.68 | ||
| 62 | |||
| 3 | ## 1.6.4 | 63 | ## 1.6.4 |
| 4 | 64 | ||
| 5 | ### Patch Changes | 65 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/local", | 2 | "name": "@executor-js/local", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "exports": { | 6 | "exports": { |
| @@ -10,7 +10,7 @@ · 3 unmodified lines | |||
| 10 | "scripts": { | 10 | "scripts": { |
| 11 | "dev": "bun run dev:proxy && bun run dev:vite", | 11 | "dev": "bun run dev:proxy && bun run dev:vite", |
| 12 | "dev:proxy": "portless proxy start --multiplex --port 1355 || true", | 12 | "dev:proxy": "portless proxy start --multiplex --port 1355 || true", |
| 13 | "dev:vite": "EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name executor-local bunx --bun vite dev", | 13 | "dev:vite": "EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name \"executor-local-$(basename \"$(cd ../.. && pwd)\")\" bunx --bun vite dev", |
| 14 | "build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build", | 14 | "build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build", |
| 15 | "start": "bun run src/serve.ts", | 15 | "start": "bun run src/serve.ts", |
| 16 | "db:generate": "drizzle-kit generate", | 16 | "db:generate": "drizzle-kit generate", |
| @@ -0,0 +1,222 @@ | |||
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | ||
| 2 | // oxlint-disable-next-line executor/no-vitest-import -- boundary: deterministic timer control comes from vitest itself | ||
| 3 | import { vi } from "vitest"; | ||
| 4 | import { OAUTH_POPUP_MESSAGE_TYPE, type OAuthPopupResult } from "@executor-js/sdk"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | __oauthAwaitHeldWaiterTotalForTests, | ||
| 8 | __oauthAwaitWaiterCountForTests, | ||
| 9 | __resetOAuthResultStoreForTests, | ||
| 10 | consumeOAuthResult, | ||
| 11 | publishOAuthResult, | ||
| 12 | waitForOAuthResult, | ||
| 13 | } from "./oauth-result-store"; | ||
| 14 | |||
| 15 | const sampleResult = (sessionId: string): OAuthPopupResult<unknown> => ({ | ||
| 16 | type: OAUTH_POPUP_MESSAGE_TYPE, | ||
| 17 | ok: false, | ||
| 18 | sessionId, | ||
| 19 | error: "access denied", | ||
| 20 | }); | ||
| 21 | |||
| 22 | afterEach(() => { | ||
| 23 | __resetOAuthResultStoreForTests(); | ||
| 24 | vi.useRealTimers(); | ||
| 25 | }); | ||
| 26 | |||
| 27 | // Waiter registration, publish wake-ups, aborts, and the over-cap instant | ||
| 28 | // answer are all synchronous, so every ordering below is exact — no sleeps. | ||
| 29 | |||
| 30 | describe("waitForOAuthResult", () => { | ||
| 31 | it("resolves immediately and consumes when the result is already published", async () => { | ||
| 32 | publishOAuthResult(sampleResult("s-ready")); | ||
| 33 | |||
| 34 | const result = await waitForOAuthResult("s-ready", { timeoutMs: 5000 }); | ||
| 35 | |||
| 36 | expect(result).toMatchObject({ sessionId: "s-ready" }); | ||
| 37 | // One-shot: the wait consumed the entry. | ||
| 38 | expect(consumeOAuthResult("s-ready")).toBeNull(); | ||
| 39 | expect(__oauthAwaitWaiterCountForTests("s-ready")).toBe(0); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("resolves a held wait the moment the result is published", async () => { | ||
| 43 | const pending = waitForOAuthResult("s-mid", { timeoutMs: 5000 }); | ||
| 44 | expect(__oauthAwaitWaiterCountForTests("s-mid")).toBe(1); | ||
| 45 | |||
| 46 | const publishedAt = Date.now(); | ||
| 47 | publishOAuthResult(sampleResult("s-mid")); | ||
| 48 | const result = await pending; | ||
| 49 | |||
| 50 | // Resolved by the publish, not the 5s deadline. | ||
| 51 | expect(Date.now() - publishedAt).toBeLessThan(1000); | ||
| 52 | expect(result).toMatchObject({ sessionId: "s-mid" }); | ||
| 53 | expect(consumeOAuthResult("s-mid")).toBeNull(); | ||
| 54 | expect(__oauthAwaitWaiterCountForTests("s-mid")).toBe(0); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("returns null at the deadline and removes the waiter", async () => { | ||
| 58 | vi.useFakeTimers(); | ||
| 59 | const pending = waitForOAuthResult("s-deadline", { timeoutMs: 25_000 }); | ||
| 60 | expect(__oauthAwaitWaiterCountForTests("s-deadline")).toBe(1); | ||
| 61 | |||
| 62 | vi.advanceTimersByTime(25_000); | ||
| 63 | |||
| 64 | expect(await pending).toBeNull(); | ||
| 65 | expect(__oauthAwaitWaiterCountForTests("s-deadline")).toBe(0); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("resolves immediately when the signal is already aborted", async () => { | ||
| 69 | const controller = new AbortController(); | ||
| 70 | controller.abort(); | ||
| 71 | |||
| 72 | const result = await waitForOAuthResult("s-pre-aborted", { | ||
| 73 | timeoutMs: 5000, | ||
| 74 | signal: controller.signal, | ||
| 75 | }); | ||
| 76 | |||
| 77 | expect(result).toBeNull(); | ||
| 78 | expect(__oauthAwaitWaiterCountForTests("s-pre-aborted")).toBe(0); | ||
| 79 | }); | ||
| 80 | }); | ||
| 81 | |||
| 82 | describe("waitForOAuthResult publish/abort races", () => { | ||
| 83 | it("abort settles first; a publish arriving after leaves the result for the next consumer", async () => { | ||
| 84 | const controller = new AbortController(); | ||
| 85 | const pending = waitForOAuthResult("s-abort-first", { | ||
| 86 | timeoutMs: 5000, | ||
| 87 | signal: controller.signal, | ||
| 88 | }); | ||
| 89 | expect(__oauthAwaitWaiterCountForTests("s-abort-first")).toBe(1); | ||
| 90 | |||
| 91 | controller.abort(); | ||
| 92 | expect(await pending).toBeNull(); | ||
| 93 | expect(__oauthAwaitWaiterCountForTests("s-abort-first")).toBe(0); | ||
| 94 | |||
| 95 | // The publish lands after the abort settled: the dead waiter must not | ||
| 96 | // consume it — it stays in the store for the client's next poll. | ||
| 97 | publishOAuthResult(sampleResult("s-abort-first")); | ||
| 98 | expect(consumeOAuthResult("s-abort-first")).toMatchObject({ sessionId: "s-abort-first" }); | ||
| 99 | }); | ||
| 100 | |||
| 101 | it("publish resolves the waiter; an abort firing immediately after does not double-consume", async () => { | ||
| 102 | const controller = new AbortController(); | ||
| 103 | const pending = waitForOAuthResult("s-pub-first", { | ||
| 104 | timeoutMs: 5000, | ||
| 105 | signal: controller.signal, | ||
| 106 | }); | ||
| 107 | expect(__oauthAwaitWaiterCountForTests("s-pub-first")).toBe(1); | ||
| 108 | |||
| 109 | publishOAuthResult(sampleResult("s-pub-first")); | ||
| 110 | controller.abort(); | ||
| 111 | |||
| 112 | expect(await pending).toMatchObject({ sessionId: "s-pub-first" }); | ||
| 113 | expect(__oauthAwaitWaiterCountForTests("s-pub-first")).toBe(0); | ||
| 114 | |||
| 115 | // The late abort must not have consumed or dropped anything: a second | ||
| 116 | // publish for the session is still delivered intact. | ||
| 117 | publishOAuthResult(sampleResult("s-pub-first")); | ||
| 118 | expect(consumeOAuthResult("s-pub-first")).toMatchObject({ sessionId: "s-pub-first" }); | ||
| 119 | }); | ||
| 120 | }); | ||
| 121 | |||
| 122 | describe("waitForOAuthResult publish/timeout races", () => { | ||
| 123 | it("deadline fires first; a publish just after leaves the result consumable", async () => { | ||
| 124 | vi.useFakeTimers(); | ||
| 125 | const pending = waitForOAuthResult("s-late-pub", { timeoutMs: 25_000 }); | ||
| 126 | expect(__oauthAwaitWaiterCountForTests("s-late-pub")).toBe(1); | ||
| 127 | |||
| 128 | vi.advanceTimersByTime(25_000); | ||
| 129 | expect(await pending).toBeNull(); | ||
| 130 | expect(__oauthAwaitWaiterCountForTests("s-late-pub")).toBe(0); | ||
| 131 | |||
| 132 | publishOAuthResult(sampleResult("s-late-pub")); | ||
| 133 | expect(consumeOAuthResult("s-late-pub")).toMatchObject({ sessionId: "s-late-pub" }); | ||
| 134 | }); | ||
| 135 | |||
| 136 | it("publish resolves the waiter; the stale deadline timer is inert afterwards", async () => { | ||
| 137 | vi.useFakeTimers(); | ||
| 138 | const pending = waitForOAuthResult("s-early-pub", { timeoutMs: 25_000 }); | ||
| 139 | expect(__oauthAwaitWaiterCountForTests("s-early-pub")).toBe(1); | ||
| 140 | |||
| 141 | publishOAuthResult(sampleResult("s-early-pub")); | ||
| 142 | expect(await pending).toMatchObject({ sessionId: "s-early-pub" }); | ||
| 143 | expect(__oauthAwaitWaiterCountForTests("s-early-pub")).toBe(0); | ||
| 144 | |||
| 145 | // Run the clock past the original deadline: the settled waiter's timer | ||
| 146 | // was cleared, so nothing re-fires, re-registers, or consumes again. | ||
| 147 | vi.advanceTimersByTime(25_000); | ||
| 148 | expect(__oauthAwaitWaiterCountForTests("s-early-pub")).toBe(0); | ||
| 149 | publishOAuthResult(sampleResult("s-early-pub")); | ||
| 150 | expect(consumeOAuthResult("s-early-pub")).toMatchObject({ sessionId: "s-early-pub" }); | ||
| 151 | }); | ||
| 152 | }); | ||
| 153 | |||
| 154 | describe("waitForOAuthResult held-waiter caps", () => { | ||
| 155 | it("holds one waiter per session; stacked requests answer null immediately (old-client behavior)", async () => { | ||
| 156 | // An unpatched desktop client polls every second and would stack ~25 | ||
| 157 | // concurrent requests per flow against a holding server. Only the first | ||
| 158 | // may hold; the rest get the pre-long-poll instant "still pending". | ||
| 159 | const held = waitForOAuthResult("s-stack", { timeoutMs: 5000 }); | ||
| 160 | expect(__oauthAwaitWaiterCountForTests("s-stack")).toBe(1); | ||
| 161 | |||
| 162 | const stacked = [ | ||
| 163 | waitForOAuthResult("s-stack", { timeoutMs: 5000 }), | ||
| 164 | waitForOAuthResult("s-stack", { timeoutMs: 5000 }), | ||
| 165 | waitForOAuthResult("s-stack", { timeoutMs: 5000 }), | ||
| 166 | ]; | ||
| 167 | // The stacked requests settle null BEFORE any publish — instant answers. | ||
| 168 | for (const request of stacked) expect(await request).toBeNull(); | ||
| 169 | expect(__oauthAwaitWaiterCountForTests("s-stack")).toBe(1); | ||
| 170 | expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(1); | ||
| 171 | |||
| 172 | publishOAuthResult(sampleResult("s-stack")); | ||
| 173 | // Exactly one consumer receives the one-shot result: the held waiter. | ||
| 174 | expect(await held).toMatchObject({ sessionId: "s-stack" }); | ||
| 175 | expect(consumeOAuthResult("s-stack")).toBeNull(); | ||
| 176 | // No leaked waiters after the flow. | ||
| 177 | expect(__oauthAwaitWaiterCountForTests("s-stack")).toBe(0); | ||
| 178 | expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(0); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it("re-arms the per-session hold after the held waiter settles", async () => { | ||
| 182 | const controller = new AbortController(); | ||
| 183 | const first = waitForOAuthResult("s-rearm", { timeoutMs: 5000, signal: controller.signal }); | ||
| 184 | // Over the per-session cap while the first is held. | ||
| 185 | expect(await waitForOAuthResult("s-rearm", { timeoutMs: 5000 })).toBeNull(); | ||
| 186 | |||
| 187 | controller.abort(); | ||
| 188 | expect(await first).toBeNull(); | ||
| 189 | expect(__oauthAwaitWaiterCountForTests("s-rearm")).toBe(0); | ||
| 190 | |||
| 191 | // The slot is free again: the next request holds and gets the result. | ||
| 192 | const second = waitForOAuthResult("s-rearm", { timeoutMs: 5000 }); | ||
| 193 | expect(__oauthAwaitWaiterCountForTests("s-rearm")).toBe(1); | ||
| 194 | publishOAuthResult(sampleResult("s-rearm")); | ||
| 195 | expect(await second).toMatchObject({ sessionId: "s-rearm" }); | ||
| 196 | expect(__oauthAwaitWaiterCountForTests("s-rearm")).toBe(0); | ||
| 197 | }); | ||
| 198 | |||
| 199 | it("caps total held waiters globally; over-cap sessions answer instantly but stored results still deliver", async () => { | ||
| 200 | const held = Array.from({ length: 64 }, (_, index) => | ||
| 201 | waitForOAuthResult(`s-global-${index}`, { timeoutMs: 5000 }), | ||
| 202 | ); | ||
| 203 | expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(64); | ||
| 204 | |||
| 205 | // A distinct pending session over the global cap answers null instantly | ||
| 206 | // instead of holding. | ||
| 207 | expect(await waitForOAuthResult("s-global-over", { timeoutMs: 5000 })).toBeNull(); | ||
| 208 | expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(64); | ||
| 209 | |||
| 210 | // Over-cap behavior matches the pre-long-poll server exactly: a result | ||
| 211 | // already in the store is still consumed and answered immediately. | ||
| 212 | publishOAuthResult(sampleResult("s-global-stored")); | ||
| 213 | expect(await waitForOAuthResult("s-global-stored", { timeoutMs: 5000 })).toMatchObject({ | ||
| 214 | sessionId: "s-global-stored", | ||
| 215 | }); | ||
| 216 | |||
| 217 | // Settle every held waiter and confirm the registry drains completely. | ||
| 218 | __resetOAuthResultStoreForTests(); | ||
| 219 | for (const request of held) expect(await request).toBeNull(); | ||
| 220 | expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(0); | ||
| 221 | }); | ||
| 222 | }); | ||
| @@ -28,6 +28,37 @@ const RESULT_TTL_MS = 10 * 60 * 1000; // 10 minutes — long enough for slow MFA · 27 unmodified lines | |||
| 28 | 28 | ||
| 29 | const store = new Map<string, StoredResult>(); | 29 | const store = new Map<string, StoredResult>(); |
| 30 | 30 | ||
| 31 | /** | ||
| 32 | * Long-poll waiters, keyed by sessionId. Each entry is the wake callback for | ||
| 33 | * the single held `/api/oauth/await/:sessionId` request for that session. | ||
| 34 | * `publishOAuthResult` wakes the waiter, which runs `consumeOAuthResult` and | ||
| 35 | * answers with the result. | ||
| 36 | * | ||
| 37 | * Held waiters are bounded — each pins a connection, a registry entry, and a | ||
| 38 | * deadline timer, and the route only needs a bearer, so unbounded holds are a | ||
| 39 | * DoS surface. At most one waiter is held per session (the patched client | ||
| 40 | * polls sequentially; an unpatched client polling every second would | ||
| 41 | * otherwise stack ~25 holds per flow against a 25s deadline), and at most | ||
| 42 | * `MAX_HELD_WAITERS_TOTAL` across all sessions. Over either bound, | ||
| 43 | * `waitForOAuthResult` degrades to the pre-long-poll behavior: it answers the | ||
| 44 | * current store value immediately (null = still pending) instead of holding, | ||
| 45 | * so over-cap callers see exactly what an old server would have sent. | ||
| 46 | */ | ||
| 47 | const MAX_HELD_WAITERS_TOTAL = 64; | ||
| 48 | |||
| 49 | const waiters = new Map<string, () => void>(); | ||
| 50 | |||
| 51 | const removeWaiter = (sessionId: string, wake: () => void): void => { | ||
| 52 | if (waiters.get(sessionId) === wake) waiters.delete(sessionId); | ||
| 53 | }; | ||
| 54 | |||
| 55 | const wakeWaiter = (sessionId: string): void => { | ||
| 56 | const wake = waiters.get(sessionId); | ||
| 57 | if (!wake) return; | ||
| 58 | waiters.delete(sessionId); | ||
| 59 | wake(); | ||
| 60 | }; | ||
| 61 | |||
| 31 | const cleanupExpired = (now: number) => { | 62 | const cleanupExpired = (now: number) => { |
| 32 | for (const [sessionId, entry] of store) { | 63 | for (const [sessionId, entry] of store) { |
| 33 | if (entry.expiresAt < now) store.delete(sessionId); | 64 | if (entry.expiresAt < now) store.delete(sessionId); |
| @@ -44,6 +75,7 @@ export const publishOAuthResult = (result: AnyResult): void => { · 10 unmodified lines | |||
| 44 | const now = Date.now(); | 75 | const now = Date.now(); |
| 45 | cleanupExpired(now); | 76 | cleanupExpired(now); |
| 46 | store.set(sessionId, { result, expiresAt: now + RESULT_TTL_MS }); | 77 | store.set(sessionId, { result, expiresAt: now + RESULT_TTL_MS }); |
| 78 | wakeWaiter(sessionId); | ||
| 47 | }; | 79 | }; |
| 48 | 80 | ||
| 49 | /** | 81 | /** |
| @@ -59,7 +91,57 @@ export const consumeOAuthResult = (sessionId: string): AnyResult | null => { · 9 unmodified lines | |||
| 59 | return entry.result; | 91 | return entry.result; |
| 60 | }; | 92 | }; |
| 61 | 93 | ||
| 62 | /** Test-only — clears the entire store between tests. */ | 94 | /** |
| 95 | * Long-poll for a result. Consumes and resolves immediately when a result | ||
| 96 | * is already stored; otherwise holds until `publishOAuthResult` fires for | ||
| 97 | * the sessionId, the deadline elapses, or `signal` aborts (client gone). | ||
| 98 | * The latter two resolve `null` — the same "still pending" answer an | ||
| 99 | * immediate poll gives — so the caller's retry loop keeps working. A | ||
| 100 | * waiter that times out or aborts is always removed from the registry. | ||
| 101 | * | ||
| 102 | * Holding is bounded (see the waiter registry above): when the session | ||
| 103 | * already has a held waiter, or the global held-waiter ceiling is reached, | ||
| 104 | * this answers `null` immediately instead of holding. | ||
| 105 | */ | ||
| 106 | export const waitForOAuthResult = ( | ||
| 107 | sessionId: string, | ||
| 108 | opts: { readonly timeoutMs: number; readonly signal?: AbortSignal }, | ||
| 109 | ): Promise<AnyResult | null> => { | ||
| 110 | const immediate = consumeOAuthResult(sessionId); | ||
| 111 | if (immediate !== null) return Promise.resolve(immediate); | ||
| 112 | if (opts.timeoutMs <= 0 || opts.signal?.aborted === true) return Promise.resolve(null); | ||
| 113 | if (waiters.has(sessionId) || waiters.size >= MAX_HELD_WAITERS_TOTAL) { | ||
| 114 | return Promise.resolve(null); | ||
| 115 | } | ||
| 116 | |||
| 117 | return new Promise((resolve) => { | ||
| 118 | let done = false; | ||
| 119 | const finish = (result: AnyResult | null) => { | ||
| 120 | if (done) return; | ||
| 121 | done = true; | ||
| 122 | clearTimeout(timer); | ||
| 123 | opts.signal?.removeEventListener("abort", onAbort); | ||
| 124 | removeWaiter(sessionId, wake); | ||
| 125 | resolve(result); | ||
| 126 | }; | ||
| 127 | const wake = () => finish(consumeOAuthResult(sessionId)); | ||
| 128 | const onAbort = () => finish(null); | ||
| 129 | const timer = setTimeout(() => finish(null), opts.timeoutMs); | ||
| 130 | |||
| 131 | waiters.set(sessionId, wake); | ||
| 132 | opts.signal?.addEventListener("abort", onAbort, { once: true }); | ||
| 133 | }); | ||
| 134 | }; | ||
| 135 | |||
| 136 | /** Test-only — clears the store and resolves any held waiters as pending. */ | ||
| 63 | export const __resetOAuthResultStoreForTests = (): void => { | 137 | export const __resetOAuthResultStoreForTests = (): void => { |
| 64 | store.clear(); | 138 | store.clear(); |
| 139 | for (const sessionId of [...waiters.keys()]) wakeWaiter(sessionId); | ||
| 65 | }; | 140 | }; |
| 141 | |||
| 142 | /** Test-only — number of held long-poll waiters for a sessionId (0 or 1). */ | ||
| 143 | export const __oauthAwaitWaiterCountForTests = (sessionId: string): number => | ||
| 144 | waiters.has(sessionId) ? 1 : 0; | ||
| 145 | |||
| 146 | /** Test-only — total held long-poll waiters across all sessions. */ | ||
| 147 | export const __oauthAwaitHeldWaiterTotalForTests = (): number => waiters.size; | ||
| @@ -3,7 +3,16 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } f · 2 unmodified lines | |||
| 3 | import { tmpdir } from "node:os"; | 3 | import { tmpdir } from "node:os"; |
| 4 | import { join } from "node:path"; | 4 | import { join } from "node:path"; |
| 5 | 5 | ||
| 6 | import { OAUTH_POPUP_MESSAGE_TYPE, type OAuthPopupResult } from "@executor-js/sdk"; | ||
| 7 | |||
| 6 | import { acquireDataDirOwnership } from "./db/data-dir-ownership"; | 8 | import { acquireDataDirOwnership } from "./db/data-dir-ownership"; |
| 9 | import { | ||
| 10 | __oauthAwaitHeldWaiterTotalForTests, | ||
| 11 | __oauthAwaitWaiterCountForTests, | ||
| 12 | __resetOAuthResultStoreForTests, | ||
| 13 | consumeOAuthResult, | ||
| 14 | publishOAuthResult, | ||
| 15 | } from "./oauth-result-store"; | ||
| 7 | import { startServer, type ServerInstance } from "./serve"; | 16 | import { startServer, type ServerInstance } from "./serve"; |
| 8 | 17 | ||
| 9 | let clientDir: string; | 18 | let clientDir: string; |
| @@ -219,6 +228,121 @@ describe("startServer bearer auth", () => { · 209 unmodified lines | |||
| 219 | }); | 228 | }); |
| 220 | }); | 229 | }); |
| 221 | 230 | ||
| 231 | describe("startServer OAuth await long-poll", () => { | ||
| 232 | afterEach(() => { | ||
| 233 | __resetOAuthResultStoreForTests(); | ||
| 234 | }); | ||
| 235 | |||
| 236 | const untilWaiterCount = async (sessionId: string, count: number): Promise<void> => { | ||
| 237 | const deadline = Date.now() + 2000; | ||
| 238 | while (__oauthAwaitWaiterCountForTests(sessionId) !== count && Date.now() < deadline) { | ||
| 239 | await new Promise<void>((resolve) => setTimeout(resolve, 5)); | ||
| 240 | } | ||
| 241 | expect(__oauthAwaitWaiterCountForTests(sessionId)).toBe(count); | ||
| 242 | }; | ||
| 243 | |||
| 244 | const awaitResult = (sessionId: string): OAuthPopupResult<unknown> => ({ | ||
| 245 | type: OAUTH_POPUP_MESSAGE_TYPE, | ||
| 246 | ok: false, | ||
| 247 | sessionId, | ||
| 248 | error: "access denied", | ||
| 249 | }); | ||
| 250 | |||
| 251 | it("holds the await request open and answers the moment the result publishes", async () => { | ||
| 252 | const baseUrl = await startTestServer(); | ||
| 253 | |||
| 254 | const pending = fetch(`${baseUrl}/api/oauth/await/session-hold`, { | ||
| 255 | headers: { authorization: `Bearer ${TOKEN}` }, | ||
| 256 | }); | ||
| 257 | // The request is being held, not answered null immediately. | ||
| 258 | await untilWaiterCount("session-hold", 1); | ||
| 259 | |||
| 260 | const publishedAt = Date.now(); | ||
| 261 | publishOAuthResult(awaitResult("session-hold")); | ||
| 262 | const response = await pending; | ||
| 263 | const body = (await response.json()) as unknown; | ||
| 264 | |||
| 265 | // Resolved by the publish, not a poll tick or the 25s deadline. | ||
| 266 | expect(Date.now() - publishedAt).toBeLessThan(1000); | ||
| 267 | expect(body).toMatchObject({ sessionId: "session-hold", ok: false }); | ||
| 268 | expect(__oauthAwaitWaiterCountForTests("session-hold")).toBe(0); | ||
| 269 | }); | ||
| 270 | |||
| 271 | it("answers a pre-published result without waiting", async () => { | ||
| 272 | const baseUrl = await startTestServer(); | ||
| 273 | publishOAuthResult(awaitResult("session-ready")); | ||
| 274 | |||
| 275 | const response = await fetch(`${baseUrl}/api/oauth/await/session-ready`, { | ||
| 276 | headers: { authorization: `Bearer ${TOKEN}` }, | ||
| 277 | }); | ||
| 278 | const body = (await response.json()) as unknown; | ||
| 279 | |||
| 280 | expect(body).toMatchObject({ sessionId: "session-ready" }); | ||
| 281 | // One-shot: a second poll for the same session finds nothing. | ||
| 282 | expect(consumeOAuthResult("session-ready")).toBeNull(); | ||
| 283 | }); | ||
| 284 | |||
| 285 | it("drops the waiter when the client disconnects", async () => { | ||
| 286 | const baseUrl = await startTestServer(); | ||
| 287 | |||
| 288 | const controller = new AbortController(); | ||
| 289 | const pending = fetch(`${baseUrl}/api/oauth/await/session-gone`, { | ||
| 290 | headers: { authorization: `Bearer ${TOKEN}` }, | ||
| 291 | signal: controller.signal, | ||
| 292 | }); | ||
| 293 | await untilWaiterCount("session-gone", 1); | ||
| 294 | |||
| 295 | controller.abort(); | ||
| 296 | await expect(pending).rejects.toThrow(); | ||
| 297 | await untilWaiterCount("session-gone", 0); | ||
| 298 | |||
| 299 | // The dead waiter must not consume a result published afterwards. | ||
| 300 | publishOAuthResult(awaitResult("session-gone")); | ||
| 301 | expect(consumeOAuthResult("session-gone")).toMatchObject({ sessionId: "session-gone" }); | ||
| 302 | }); | ||
| 303 | |||
| 304 | it("holds one request per session; stacked requests answer null instantly and one consumer wins", async () => { | ||
| 305 | // The mixed-version rollout hazard: an unpatched desktop client polls | ||
| 306 | // every second and would stack ~25 concurrent requests per flow against | ||
| 307 | // a holding server. Only the first request may hold — the rest must get | ||
| 308 | // the pre-long-poll instant "still pending" answer. | ||
| 309 | const baseUrl = await startTestServer(); | ||
| 310 | |||
| 311 | const held = fetch(`${baseUrl}/api/oauth/await/session-stacked`, { | ||
| 312 | headers: { authorization: `Bearer ${TOKEN}` }, | ||
| 313 | }); | ||
| 314 | // Deterministic ordering: the first request is registered before any | ||
| 315 | // stacked request is issued. | ||
| 316 | await untilWaiterCount("session-stacked", 1); | ||
| 317 | |||
| 318 | const stacked = await Promise.all( | ||
| 319 | [1, 2, 3].map(() => | ||
| 320 | fetch(`${baseUrl}/api/oauth/await/session-stacked`, { | ||
| 321 | headers: { authorization: `Bearer ${TOKEN}` }, | ||
| 322 | }), | ||
| 323 | ), | ||
| 324 | ); | ||
| 325 | // The stacked requests settled BEFORE any publish — instant null answers, | ||
| 326 | // not held connections. | ||
| 327 | for (const response of stacked) { | ||
| 328 | expect(response.status).toBe(200); | ||
| 329 | expect(await response.json()).toBeNull(); | ||
| 330 | } | ||
| 331 | expect(__oauthAwaitWaiterCountForTests("session-stacked")).toBe(1); | ||
| 332 | expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(1); | ||
| 333 | |||
| 334 | publishOAuthResult(awaitResult("session-stacked")); | ||
| 335 | const response = await held; | ||
| 336 | // Exactly one consumer overall receives the one-shot result: the held | ||
| 337 | // request. Nothing is left behind for a later poll. | ||
| 338 | expect(await response.json()).toMatchObject({ sessionId: "session-stacked" }); | ||
| 339 | expect(consumeOAuthResult("session-stacked")).toBeNull(); | ||
| 340 | // The registry drains completely — no leaked waiters. | ||
| 341 | expect(__oauthAwaitWaiterCountForTests("session-stacked")).toBe(0); | ||
| 342 | expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(0); | ||
| 343 | }); | ||
| 344 | }); | ||
| 345 | |||
| 222 | describe("startServer CORS hardening", () => { | 346 | describe("startServer CORS hardening", () => { |
| 223 | it("reflects credentialed CORS only for allowed loopback origins", async () => { | 347 | it("reflects credentialed CORS only for allowed loopback origins", async () => { |
| 224 | const baseUrl = await startTestServer(); | 348 | const baseUrl = await startTestServer(); |
| @@ -13,7 +13,7 @@ import type { Subprocess } from "bun"; · 12 unmodified lines | |||
| 13 | import { setOAuthCompletionListener } from "@executor-js/api"; | 13 | import { setOAuthCompletionListener } from "@executor-js/api"; |
| 14 | import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server"; | 14 | import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server"; |
| 15 | import { loadOrMintLocalAuthToken } from "./auth"; | 15 | import { loadOrMintLocalAuthToken } from "./auth"; |
| 16 | import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store"; | 16 | import { publishOAuthResult, waitForOAuthResult } from "./oauth-result-store"; |
| 17 | import { disposeAnalytics } from "./analytics"; | 17 | import { disposeAnalytics } from "./analytics"; |
| 18 | import { startIntegrationsRefresh } from "./integrations"; | 18 | import { startIntegrationsRefresh } from "./integrations"; |
| 19 | import { disposeServerHandlers, getServerHandlers } from "./main"; | 19 | import { disposeServerHandlers, getServerHandlers } from "./main"; |
| @@ -27,6 +27,11 @@ import { · 7 unmodified lines | |||
| 27 | normalizeCredential, | 27 | normalizeCredential, |
| 28 | } from "./serve-shared"; | 28 | } from "./serve-shared"; |
| 29 | 29 | ||
| 30 | // How long one /api/oauth/await request is held open waiting for the OAuth | ||
| 31 | // completion to be published. Shorter than the client's overall timeout; the | ||
| 32 | // client reconnects after each deadline, so this only bounds a single hold. | ||
| 33 | const OAUTH_AWAIT_LONG_POLL_DEADLINE_MS = 25_000; | ||
| 34 | |||
| 30 | // --------------------------------------------------------------------------- | 35 | // --------------------------------------------------------------------------- |
| 31 | // Static files | 36 | // Static files |
| 32 | // --------------------------------------------------------------------------- | 37 | // --------------------------------------------------------------------------- |
| @@ -457,9 +462,20 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 424 unmodified lines | |||
| 457 | // OAuth result polling — local-only, served outside the typed API | 462 | // OAuth result polling — local-only, served outside the typed API |
| 458 | // because cloud (Cloudflare Workers, stateless) can't back the | 463 | // because cloud (Cloudflare Workers, stateless) can't back the |
| 459 | // in-memory store. See setOAuthCompletionListener above. | 464 | // in-memory store. See setOAuthCompletionListener above. |
| 465 | // Long-poll: hold the request until the completion listener publishes | ||
| 466 | // for this session, then answer immediately instead of making the | ||
| 467 | // client wait for its next poll tick. On deadline (or client | ||
| 468 | // disconnect) answer null — "still pending" — so the client's outer | ||
| 469 | // retry loop reconnects. `idleTimeout: 0` above permits the hold. | ||
| 470 | // Holds are bounded (one per session, small global cap — see | ||
| 471 | // oauth-result-store.ts); over-cap requests answer immediately with | ||
| 472 | // the current store value, i.e. the pre-long-poll poll behavior. | ||
| 460 | const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname); | 473 | const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname); |
| 461 | if (awaitMatch && req.method === "GET") { | 474 | if (awaitMatch && req.method === "GET") { |
| 462 | const result = consumeOAuthResult(awaitMatch[1]); | 475 | const result = await waitForOAuthResult(awaitMatch[1], { |
| 476 | timeoutMs: OAUTH_AWAIT_LONG_POLL_DEADLINE_MS, | ||
| 477 | signal: req.signal, | ||
| 478 | }); | ||
| 463 | return withCors( | 479 | return withCors( |
| 464 | new Response(JSON.stringify(result), { | 480 | new Response(JSON.stringify(result), { |
| 465 | headers: { "content-type": "application/json" }, | 481 | headers: { "content-type": "application/json" }, |
| @@ -31,7 +31,7 @@ · 30 unmodified lines | |||
| 31 | }, | 31 | }, |
| 32 | "apps/cli": { | 32 | "apps/cli": { |
| 33 | "name": "executor", | 33 | "name": "executor", |
| 34 | "version": "1.6.4", | 34 | "version": "1.6.6", |
| 35 | "bin": { | 35 | "bin": { |
| 36 | "executor": "./bin/executor.ts", | 36 | "executor": "./bin/executor.ts", |
| 37 | }, | 37 | }, |
| @@ -60,7 +60,7 @@ · 22 unmodified lines | |||
| 60 | }, | 60 | }, |
| 61 | "apps/cloud": { | 61 | "apps/cloud": { |
| 62 | "name": "@executor-js/cloud", | 62 | "name": "@executor-js/cloud", |
| 63 | "version": "1.4.65", | 63 | "version": "1.4.67", |
| 64 | "dependencies": { | 64 | "dependencies": { |
| 65 | "@cloudflare/vite-plugin": "^1.31.1", | 65 | "@cloudflare/vite-plugin": "^1.31.1", |
| 66 | "@effect/atom-react": "catalog:", | 66 | "@effect/atom-react": "catalog:", |
| @@ -133,7 +133,7 @@ · 66 unmodified lines | |||
| 133 | }, | 133 | }, |
| 134 | "apps/desktop": { | 134 | "apps/desktop": { |
| 135 | "name": "@executor-js/desktop", | 135 | "name": "@executor-js/desktop", |
| 136 | "version": "1.6.4", | 136 | "version": "1.6.6", |
| 137 | "dependencies": { | 137 | "dependencies": { |
| 138 | "@sentry/bun": "^10.57.0", | 138 | "@sentry/bun": "^10.57.0", |
| 139 | "@sentry/electron": "^7.13.0", | 139 | "@sentry/electron": "^7.13.0", |
| @@ -220,7 +220,7 @@ · 80 unmodified lines | |||
| 220 | }, | 220 | }, |
| 221 | "apps/host-selfhost": { | 221 | "apps/host-selfhost": { |
| 222 | "name": "@executor-js/host-selfhost", | 222 | "name": "@executor-js/host-selfhost", |
| 223 | "version": "0.0.46", | 223 | "version": "0.0.48", |
| 224 | "dependencies": { | 224 | "dependencies": { |
| 225 | "@better-auth/api-key": "^1.6.11", | 225 | "@better-auth/api-key": "^1.6.11", |
| 226 | "@cloudflare/worker-bundler": "0.2.1", | 226 | "@cloudflare/worker-bundler": "0.2.1", |
| @@ -272,7 +272,7 @@ · 45 unmodified lines | |||
| 272 | }, | 272 | }, |
| 273 | "apps/local": { | 273 | "apps/local": { |
| 274 | "name": "@executor-js/local", | 274 | "name": "@executor-js/local", |
| 275 | "version": "1.6.4", | 275 | "version": "1.6.6", |
| 276 | "dependencies": { | 276 | "dependencies": { |
| 277 | "@effect/atom-react": "catalog:", | 277 | "@effect/atom-react": "catalog:", |
| 278 | "@effect/platform-node": "catalog:", | 278 | "@effect/platform-node": "catalog:", |
| @@ -352,7 +352,7 @@ · 73 unmodified lines | |||
| 352 | }, | 352 | }, |
| 353 | "e2e": { | 353 | "e2e": { |
| 354 | "name": "@executor-js/e2e", | 354 | "name": "@executor-js/e2e", |
| 355 | "version": "0.0.44", | 355 | "version": "0.0.46", |
| 356 | "dependencies": { | 356 | "dependencies": { |
| 357 | "@executor-js/api": "workspace:*", | 357 | "@executor-js/api": "workspace:*", |
| 358 | "@executor-js/emulate": "^0.14.1", | 358 | "@executor-js/emulate": "^0.14.1", |
| @@ -387,7 +387,7 @@ · 28 unmodified lines | |||
| 387 | }, | 387 | }, |
| 388 | "examples/all-plugins": { | 388 | "examples/all-plugins": { |
| 389 | "name": "@executor-js/example-all-plugins", | 389 | "name": "@executor-js/example-all-plugins", |
| 390 | "version": "0.0.65", | 390 | "version": "0.0.67", |
| 391 | "dependencies": { | 391 | "dependencies": { |
| 392 | "@executor-js/plugin-file-secrets": "workspace:*", | 392 | "@executor-js/plugin-file-secrets": "workspace:*", |
| 393 | "@executor-js/plugin-graphql": "workspace:*", | 393 | "@executor-js/plugin-graphql": "workspace:*", |
| @@ -406,7 +406,7 @@ · 12 unmodified lines | |||
| 406 | }, | 406 | }, |
| 407 | "examples/docs-sdk-quickstart": { | 407 | "examples/docs-sdk-quickstart": { |
| 408 | "name": "@executor-js/example-docs-sdk-quickstart", | 408 | "name": "@executor-js/example-docs-sdk-quickstart", |
| 409 | "version": "0.0.50", | 409 | "version": "0.0.52", |
| 410 | "dependencies": { | 410 | "dependencies": { |
| 411 | "@executor-js/plugin-openapi": "workspace:*", | 411 | "@executor-js/plugin-openapi": "workspace:*", |
| 412 | "@executor-js/sdk": "workspace:*", | 412 | "@executor-js/sdk": "workspace:*", |
| @@ -463,7 +463,7 @@ · 50 unmodified lines | |||
| 463 | }, | 463 | }, |
| 464 | "packages/core/analytics": { | 464 | "packages/core/analytics": { |
| 465 | "name": "@executor-js/analytics", | 465 | "name": "@executor-js/analytics", |
| 466 | "version": "0.1.11", | 466 | "version": "0.1.13", |
| 467 | "dependencies": { | 467 | "dependencies": { |
| 468 | "@effect/platform-node": "catalog:", | 468 | "@effect/platform-node": "catalog:", |
| 469 | "@executor-js/execution": "workspace:*", | 469 | "@executor-js/execution": "workspace:*", |
| @@ -479,7 +479,7 @@ · 9 unmodified lines | |||
| 479 | }, | 479 | }, |
| 480 | "packages/core/api": { | 480 | "packages/core/api": { |
| 481 | "name": "@executor-js/api", | 481 | "name": "@executor-js/api", |
| 482 | "version": "1.4.67", | 482 | "version": "1.4.69", |
| 483 | "dependencies": { | 483 | "dependencies": { |
| 484 | "@executor-js/execution": "workspace:*", | 484 | "@executor-js/execution": "workspace:*", |
| 485 | "@executor-js/host-mcp": "workspace:*", | 485 | "@executor-js/host-mcp": "workspace:*", |
| @@ -496,7 +496,7 @@ · 10 unmodified lines | |||
| 496 | }, | 496 | }, |
| 497 | "packages/core/cli": { | 497 | "packages/core/cli": { |
| 498 | "name": "@executor-js/cli", | 498 | "name": "@executor-js/cli", |
| 499 | "version": "0.2.54", | 499 | "version": "0.2.56", |
| 500 | "bin": { | 500 | "bin": { |
| 501 | "executor-sdk": "./dist/index.js", | 501 | "executor-sdk": "./dist/index.js", |
| 502 | }, | 502 | }, |
| @@ -517,7 +517,7 @@ · 14 unmodified lines | |||
| 517 | }, | 517 | }, |
| 518 | "packages/core/config": { | 518 | "packages/core/config": { |
| 519 | "name": "@executor-js/config", | 519 | "name": "@executor-js/config", |
| 520 | "version": "1.6.4", | 520 | "version": "1.6.6", |
| 521 | "dependencies": { | 521 | "dependencies": { |
| 522 | "@executor-js/sdk": "workspace:*", | 522 | "@executor-js/sdk": "workspace:*", |
| 523 | "jiti": "^2.6.1", | 523 | "jiti": "^2.6.1", |
| @@ -538,7 +538,7 @@ · 14 unmodified lines | |||
| 538 | }, | 538 | }, |
| 539 | "packages/core/execution": { | 539 | "packages/core/execution": { |
| 540 | "name": "@executor-js/execution", | 540 | "name": "@executor-js/execution", |
| 541 | "version": "1.6.4", | 541 | "version": "1.6.6", |
| 542 | "dependencies": { | 542 | "dependencies": { |
| 543 | "@executor-js/codemode-core": "workspace:*", | 543 | "@executor-js/codemode-core": "workspace:*", |
| 544 | "@executor-js/sdk": "workspace:*", | 544 | "@executor-js/sdk": "workspace:*", |
| @@ -604,7 +604,7 @@ · 59 unmodified lines | |||
| 604 | }, | 604 | }, |
| 605 | "packages/core/sdk": { | 605 | "packages/core/sdk": { |
| 606 | "name": "@executor-js/sdk", | 606 | "name": "@executor-js/sdk", |
| 607 | "version": "1.6.4", | 607 | "version": "1.6.6", |
| 608 | "dependencies": { | 608 | "dependencies": { |
| 609 | "@executor-js/fumadb": "workspace:*", | 609 | "@executor-js/fumadb": "workspace:*", |
| 610 | "@standard-schema/spec": "^1.1.0", | 610 | "@standard-schema/spec": "^1.1.0", |
| @@ -657,7 +657,7 @@ · 46 unmodified lines | |||
| 657 | }, | 657 | }, |
| 658 | "packages/core/vite-plugin": { | 658 | "packages/core/vite-plugin": { |
| 659 | "name": "@executor-js/vite-plugin", | 659 | "name": "@executor-js/vite-plugin", |
| 660 | "version": "0.0.64", | 660 | "version": "0.0.66", |
| 661 | "dependencies": { | 661 | "dependencies": { |
| 662 | "@executor-js/sdk": "workspace:*", | 662 | "@executor-js/sdk": "workspace:*", |
| 663 | "jiti": "^2.6.1", | 663 | "jiti": "^2.6.1", |
| @@ -677,7 +677,7 @@ · 13 unmodified lines | |||
| 677 | }, | 677 | }, |
| 678 | "packages/hosts/cloudflare": { | 678 | "packages/hosts/cloudflare": { |
| 679 | "name": "@executor-js/cloudflare", | 679 | "name": "@executor-js/cloudflare", |
| 680 | "version": "0.0.46", | 680 | "version": "0.0.48", |
| 681 | "dependencies": { | 681 | "dependencies": { |
| 682 | "@executor-js/api": "workspace:*", | 682 | "@executor-js/api": "workspace:*", |
| 683 | "@executor-js/execution": "workspace:*", | 683 | "@executor-js/execution": "workspace:*", |
| @@ -718,7 +718,7 @@ · 34 unmodified lines | |||
| 718 | }, | 718 | }, |
| 719 | "packages/hosts/mcp-apps-shell": { | 719 | "packages/hosts/mcp-apps-shell": { |
| 720 | "name": "@executor-js/mcp-apps-shell", | 720 | "name": "@executor-js/mcp-apps-shell", |
| 721 | "version": "1.4.15", | 721 | "version": "1.4.17", |
| 722 | "dependencies": { | 722 | "dependencies": { |
| 723 | "@executor-js/react": "workspace:*", | 723 | "@executor-js/react": "workspace:*", |
| 724 | "@executor-js/runtime-quickjs": "workspace:*", | 724 | "@executor-js/runtime-quickjs": "workspace:*", |
| @@ -756,7 +756,7 @@ · 31 unmodified lines | |||
| 756 | }, | 756 | }, |
| 757 | "packages/kernel/core": { | 757 | "packages/kernel/core": { |
| 758 | "name": "@executor-js/codemode-core", | 758 | "name": "@executor-js/codemode-core", |
| 759 | "version": "1.6.4", | 759 | "version": "1.6.6", |
| 760 | "dependencies": { | 760 | "dependencies": { |
| 761 | "@babel/parser": "^7.29.2", | 761 | "@babel/parser": "^7.29.2", |
| 762 | "@standard-schema/spec": "^1.0.0", | 762 | "@standard-schema/spec": "^1.0.0", |
| @@ -829,7 +829,7 @@ · 66 unmodified lines | |||
| 829 | }, | 829 | }, |
| 830 | "packages/kernel/runtime-quickjs": { | 830 | "packages/kernel/runtime-quickjs": { |
| 831 | "name": "@executor-js/runtime-quickjs", | 831 | "name": "@executor-js/runtime-quickjs", |
| 832 | "version": "1.6.4", | 832 | "version": "1.6.6", |
| 833 | "dependencies": { | 833 | "dependencies": { |
| 834 | "@executor-js/codemode-core": "workspace:*", | 834 | "@executor-js/codemode-core": "workspace:*", |
| 835 | "quickjs-emscripten": "catalog:", | 835 | "quickjs-emscripten": "catalog:", |
| @@ -849,7 +849,7 @@ · 13 unmodified lines | |||
| 849 | }, | 849 | }, |
| 850 | "packages/kernel/runtime-workerd-subprocess": { | 850 | "packages/kernel/runtime-workerd-subprocess": { |
| 851 | "name": "@executor-js/runtime-workerd-subprocess", | 851 | "name": "@executor-js/runtime-workerd-subprocess", |
| 852 | "version": "0.0.19", | 852 | "version": "0.0.21", |
| 853 | "dependencies": { | 853 | "dependencies": { |
| 854 | "@executor-js/codemode-core": "workspace:*", | 854 | "@executor-js/codemode-core": "workspace:*", |
| 855 | "effect": "catalog:", | 855 | "effect": "catalog:", |
| @@ -862,9 +862,33 @@ · 6 unmodified lines | |||
| 862 | "vitest": "catalog:", | 862 | "vitest": "catalog:", |
| 863 | }, | 863 | }, |
| 864 | }, | 864 | }, |
| 865 | "packages/onboarding-demo": { | ||
| 866 | "name": "@executor-js/onboarding-demo", | ||
| 867 | "version": "0.0.1", | ||
| 868 | "dependencies": { | ||
| 869 | "@executor-js/plugin-mcp": "workspace:*", | ||
| 870 | "@executor-js/plugin-openapi": "workspace:*", | ||
| 871 | "@executor-js/react": "workspace:*", | ||
| 872 | "@executor-js/sdk": "workspace:*", | ||
| 873 | "lucide-react": "^1.7.0", | ||
| 874 | "react": "catalog:", | ||
| 875 | "react-dom": "catalog:", | ||
| 876 | "sonner": "^2.0.7", | ||
| 877 | "tldts": "^7.0.28", | ||
| 878 | }, | ||
| 879 | "devDependencies": { | ||
| 880 | "@tailwindcss/vite": "catalog:", | ||
| 881 | "@types/react": "catalog:", | ||
| 882 | "@types/react-dom": "catalog:", | ||
| 883 | "@vitejs/plugin-react": "catalog:", | ||
| 884 | "tailwindcss": "catalog:", | ||
| 885 | "typescript": "catalog:", | ||
| 886 | "vite": "catalog:", | ||
| 887 | }, | ||
| 888 | }, | ||
| 865 | "packages/plugins/desktop-settings": { | 889 | "packages/plugins/desktop-settings": { |
| 866 | "name": "@executor-js/plugin-desktop-settings", | 890 | "name": "@executor-js/plugin-desktop-settings", |
| 867 | "version": "1.6.4", | 891 | "version": "1.6.6", |
| 868 | "dependencies": { | 892 | "dependencies": { |
| 869 | "@executor-js/sdk": "workspace:*", | 893 | "@executor-js/sdk": "workspace:*", |
| 870 | "react": "catalog:", | 894 | "react": "catalog:", |
| @@ -877,7 +901,7 @@ · 6 unmodified lines | |||
| 877 | }, | 901 | }, |
| 878 | "packages/plugins/encrypted-secrets": { | 902 | "packages/plugins/encrypted-secrets": { |
| 879 | "name": "@executor-js/plugin-encrypted-secrets", | 903 | "name": "@executor-js/plugin-encrypted-secrets", |
| 880 | "version": "0.0.46", | 904 | "version": "0.0.48", |
| 881 | "dependencies": { | 905 | "dependencies": { |
| 882 | "@executor-js/sdk": "workspace:*", | 906 | "@executor-js/sdk": "workspace:*", |
| 883 | "effect": "catalog:", | 907 | "effect": "catalog:", |
| @@ -892,7 +916,7 @@ · 8 unmodified lines | |||
| 892 | }, | 916 | }, |
| 893 | "packages/plugins/example": { | 917 | "packages/plugins/example": { |
| 894 | "name": "@executor-js/plugin-example", | 918 | "name": "@executor-js/plugin-example", |
| 895 | "version": "1.6.4", | 919 | "version": "1.6.6", |
| 896 | "dependencies": { | 920 | "dependencies": { |
| 897 | "@executor-js/sdk": "workspace:*", | 921 | "@executor-js/sdk": "workspace:*", |
| 898 | }, | 922 | }, |
| @@ -915,7 +939,7 @@ · 16 unmodified lines | |||
| 915 | }, | 939 | }, |
| 916 | "packages/plugins/file-secrets": { | 940 | "packages/plugins/file-secrets": { |
| 917 | "name": "@executor-js/plugin-file-secrets", | 941 | "name": "@executor-js/plugin-file-secrets", |
| 918 | "version": "1.6.4", | 942 | "version": "1.6.6", |
| 919 | "dependencies": { | 943 | "dependencies": { |
| 920 | "@executor-js/sdk": "workspace:*", | 944 | "@executor-js/sdk": "workspace:*", |
| 921 | }, | 945 | }, |
| @@ -932,7 +956,7 @@ · 10 unmodified lines | |||
| 932 | }, | 956 | }, |
| 933 | "packages/plugins/graphql": { | 957 | "packages/plugins/graphql": { |
| 934 | "name": "@executor-js/plugin-graphql", | 958 | "name": "@executor-js/plugin-graphql", |
| 935 | "version": "1.6.4", | 959 | "version": "1.6.6", |
| 936 | "dependencies": { | 960 | "dependencies": { |
| 937 | "@effect/platform-node": "catalog:", | 961 | "@effect/platform-node": "catalog:", |
| 938 | "@executor-js/config": "workspace:*", | 962 | "@executor-js/config": "workspace:*", |
| @@ -971,7 +995,7 @@ · 32 unmodified lines | |||
| 971 | }, | 995 | }, |
| 972 | "packages/plugins/keychain": { | 996 | "packages/plugins/keychain": { |
| 973 | "name": "@executor-js/plugin-keychain", | 997 | "name": "@executor-js/plugin-keychain", |
| 974 | "version": "1.6.4", | 998 | "version": "1.6.6", |
| 975 | "dependencies": { | 999 | "dependencies": { |
| 976 | "@executor-js/sdk": "workspace:*", | 1000 | "@executor-js/sdk": "workspace:*", |
| 977 | "@napi-rs/keyring": "^1.2.0", | 1001 | "@napi-rs/keyring": "^1.2.0", |
| @@ -990,7 +1014,7 @@ · 12 unmodified lines | |||
| 990 | }, | 1014 | }, |
| 991 | "packages/plugins/mcp": { | 1015 | "packages/plugins/mcp": { |
| 992 | "name": "@executor-js/plugin-mcp", | 1016 | "name": "@executor-js/plugin-mcp", |
| 993 | "version": "1.6.4", | 1017 | "version": "1.6.6", |
| 994 | "dependencies": { | 1018 | "dependencies": { |
| 995 | "@cfworker/json-schema": "^4.1.1", | 1019 | "@cfworker/json-schema": "^4.1.1", |
| 996 | "@effect/platform-node": "catalog:", | 1020 | "@effect/platform-node": "catalog:", |
| @@ -1034,7 +1058,7 @@ · 37 unmodified lines | |||
| 1034 | }, | 1058 | }, |
| 1035 | "packages/plugins/onepassword": { | 1059 | "packages/plugins/onepassword": { |
| 1036 | "name": "@executor-js/plugin-onepassword", | 1060 | "name": "@executor-js/plugin-onepassword", |
| 1037 | "version": "1.6.4", | 1061 | "version": "1.6.6", |
| 1038 | "dependencies": { | 1062 | "dependencies": { |
| 1039 | "@1password/sdk": "^0.4.1-beta.1", | 1063 | "@1password/sdk": "^0.4.1-beta.1", |
| 1040 | "@effect/atom-react": "catalog:", | 1064 | "@effect/atom-react": "catalog:", |
| @@ -1067,7 +1091,7 @@ · 26 unmodified lines | |||
| 1067 | }, | 1091 | }, |
| 1068 | "packages/plugins/openapi": { | 1092 | "packages/plugins/openapi": { |
| 1069 | "name": "@executor-js/plugin-openapi", | 1093 | "name": "@executor-js/plugin-openapi", |
| 1070 | "version": "1.6.4", | 1094 | "version": "1.6.6", |
| 1071 | "dependencies": { | 1095 | "dependencies": { |
| 1072 | "@effect/platform-node": "catalog:", | 1096 | "@effect/platform-node": "catalog:", |
| 1073 | "@executor-js/config": "workspace:*", | 1097 | "@executor-js/config": "workspace:*", |
| @@ -1108,7 +1132,7 @@ · 34 unmodified lines | |||
| 1108 | }, | 1132 | }, |
| 1109 | "packages/plugins/provider-service-split": { | 1133 | "packages/plugins/provider-service-split": { |
| 1110 | "name": "@executor-js/plugin-provider-service-split", | 1134 | "name": "@executor-js/plugin-provider-service-split", |
| 1111 | "version": "0.0.18", | 1135 | "version": "0.0.20", |
| 1112 | "dependencies": { | 1136 | "dependencies": { |
| 1113 | "@executor-js/plugin-openapi": "workspace:*", | 1137 | "@executor-js/plugin-openapi": "workspace:*", |
| 1114 | "@executor-js/sdk": "workspace:*", | 1138 | "@executor-js/sdk": "workspace:*", |
| @@ -1125,7 +1149,7 @@ · 10 unmodified lines | |||
| 1125 | }, | 1149 | }, |
| 1126 | "packages/plugins/toolkits": { | 1150 | "packages/plugins/toolkits": { |
| 1127 | "name": "@executor-js/plugin-toolkits", | 1151 | "name": "@executor-js/plugin-toolkits", |
| 1128 | "version": "1.5.39", | 1152 | "version": "1.5.41", |
| 1129 | "dependencies": { | 1153 | "dependencies": { |
| 1130 | "@executor-js/sdk": "workspace:*", | 1154 | "@executor-js/sdk": "workspace:*", |
| 1131 | }, | 1155 | }, |
| @@ -1194,7 +1218,7 @@ · 62 unmodified lines | |||
| 1194 | }, | 1218 | }, |
| 1195 | "packages/react": { | 1219 | "packages/react": { |
| 1196 | "name": "@executor-js/react", | 1220 | "name": "@executor-js/react", |
| 1197 | "version": "1.4.67", | 1221 | "version": "1.4.69", |
| 1198 | "dependencies": { | 1222 | "dependencies": { |
| 1199 | "@base-ui/react": "^1.3.0", | 1223 | "@base-ui/react": "^1.3.0", |
| 1200 | "@effect/atom-react": "catalog:", | 1224 | "@effect/atom-react": "catalog:", |
| @@ -1804,6 +1828,8 @@ · 603 unmodified lines | |||
| 1804 | 1828 | ||
| 1805 | "@executor-js/motel": ["@executor-js/motel@0.2.5-executor.1", "", { "dependencies": { "@effect/atom-react": "^4.0.0-beta.49", "@effect/opentelemetry": "^4.0.0-beta.49", "@effect/platform-bun": "^4.0.0-beta.50", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@opentelemetry/sdk-logs": "^0.214.0", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentui/core": "^0.1.99", "@opentui/react": "^0.1.99", "effect": "^4.0.0-beta.49", "react": "^19.2.5", "scheduler": "^0.27.0" }, "bin": { "motel": "src/motel.ts", "motel-mcp": "src/mcp.ts" } }, "sha512-fLGJ5FIIBYd+4L2OurpYFjWzvcCbACvmdNofh6THXQJE1Gku93EKURtgn27/XddyM9SKGST6/znTRcmwKiYdXw=="], | 1829 | "@executor-js/motel": ["@executor-js/motel@0.2.5-executor.1", "", { "dependencies": { "@effect/atom-react": "^4.0.0-beta.49", "@effect/opentelemetry": "^4.0.0-beta.49", "@effect/platform-bun": "^4.0.0-beta.50", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@opentelemetry/sdk-logs": "^0.214.0", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentui/core": "^0.1.99", "@opentui/react": "^0.1.99", "effect": "^4.0.0-beta.49", "react": "^19.2.5", "scheduler": "^0.27.0" }, "bin": { "motel": "src/motel.ts", "motel-mcp": "src/mcp.ts" } }, "sha512-fLGJ5FIIBYd+4L2OurpYFjWzvcCbACvmdNofh6THXQJE1Gku93EKURtgn27/XddyM9SKGST6/znTRcmwKiYdXw=="], |
| 1806 | 1830 | ||
| 1831 | "@executor-js/onboarding-demo": ["@executor-js/onboarding-demo@workspace:packages/onboarding-demo"], | ||
| 1832 | |||
| 1807 | "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"], | 1833 | "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"], |
| 1808 | 1834 | ||
| 1809 | "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"], | 1835 | "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"], |
| @@ -1,5 +1,29 @@ | |||
| 1 | # @executor-js/e2e | 1 | # @executor-js/e2e |
| 2 | 2 | ||
| 3 | ## 0.0.46 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed)]: | ||
| 8 | - @executor-js/plugin-mcp@1.6.6 | ||
| 9 | - @executor-js/api@1.4.69 | ||
| 10 | - @executor-js/plugin-graphql@1.6.6 | ||
| 11 | - @executor-js/plugin-openapi@1.6.6 | ||
| 12 | - @executor-js/plugin-toolkits@1.5.41 | ||
| 13 | - @executor-js/sdk@1.6.6 | ||
| 14 | |||
| 15 | ## 0.0.45 | ||
| 16 | |||
| 17 | ### Patch Changes | ||
| 18 | |||
| 19 | - Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]: | ||
| 20 | - @executor-js/plugin-mcp@1.6.5 | ||
| 21 | - @executor-js/sdk@1.6.5 | ||
| 22 | - @executor-js/plugin-graphql@1.6.5 | ||
| 23 | - @executor-js/plugin-openapi@1.6.5 | ||
| 24 | - @executor-js/api@1.4.68 | ||
| 25 | - @executor-js/plugin-toolkits@1.5.40 | ||
| 26 | |||
| 3 | ## 0.0.44 | 27 | ## 0.0.44 |
| 4 | 28 | ||
| 5 | ### Patch Changes | 29 | ### Patch Changes |
| @@ -73,7 +73,9 @@ scenario( · 72 unmodified lines | |||
| 73 | 73 | ||
| 74 | await step("Open the integrations console", async () => { | 74 | await step("Open the integrations console", async () => { |
| 75 | await visit(page, "/integrations"); | 75 | await visit(page, "/integrations"); |
| 76 | await page.getByRole("button", { name: "Connect" }).first().waitFor(); | 76 | // The connect dialog became the full-page picker; the header's Add |
| 77 | // link is the page's stable loaded-signal now. | ||
| 78 | await page.getByRole("link", { name: "Add integration" }).first().waitFor(); | ||
| 77 | }); | 79 | }); |
| 78 | 80 | ||
| 79 | let faulted = 0; | 81 | let faulted = 0; |
| @@ -143,23 +143,28 @@ scenario( · 142 unmodified lines | |||
| 143 | }); | 143 | }); |
| 144 | } | 144 | } |
| 145 | // Curated entries carry the app-server bridge recipe: `codex | 145 | // Curated entries carry the app-server bridge recipe: `codex |
| 146 | // app-server` plus the server name the bridge calls tools on. | 146 | // app-server`, the server name the bridge calls tools on, and the |
| 147 | // preset it came from — that last one is what lets a macOS refusal | ||
| 148 | // name the exact grant to enable. | ||
| 147 | const messages = byId.get("codex-messages"); | 149 | const messages = byId.get("codex-messages"); |
| 148 | expect(messages?.command.endsWith("codex"), "curated entries spawn the codex CLI").toBe( | 150 | expect(messages?.command.endsWith("codex"), "curated entries spawn the codex CLI").toBe( |
| 149 | true, | 151 | true, |
| 150 | ); | 152 | ); |
| 151 | expect(messages?.args, "curated entries run the app-server").toEqual(["app-server"]); | 153 | expect(messages?.args, "curated entries run the app-server").toEqual(["app-server"]); |
| 152 | expect(messages?.appServer, "curated entries name their Codex server").toEqual({ | 154 | expect(messages?.appServer, "curated entries name their Codex server").toEqual({ |
| 155 | presetId: "codex-messages", | ||
| 153 | server: "messages", | 156 | server: "messages", |
| 154 | }); | 157 | }); |
| 155 | // Computer Use and Chrome have no server of their own: both are | 158 | // Computer Use and Chrome have no server of their own: both are |
| 156 | // projected onto `node_repl`, and Chrome carries the client module | 159 | // projected onto `node_repl`, and Chrome carries the client module |
| 157 | // its surface imports, resolved through the `latest` symlink. | 160 | // its surface imports, resolved through the `latest` symlink. |
| 158 | expect(byId.get("codex-computer-use")?.appServer).toEqual({ | 161 | expect(byId.get("codex-computer-use")?.appServer).toEqual({ |
| 162 | presetId: "codex-computer-use", | ||
| 159 | server: "node_repl", | 163 | server: "node_repl", |
| 160 | surface: "sky", | 164 | surface: "sky", |
| 161 | }); | 165 | }); |
| 162 | expect(byId.get("codex-chrome")?.appServer).toEqual({ | 166 | expect(byId.get("codex-chrome")?.appServer).toEqual({ |
| 167 | presetId: "codex-chrome", | ||
| 163 | server: "node_repl", | 168 | server: "node_repl", |
| 164 | surface: "browser", | 169 | surface: "browser", |
| 165 | modulePath: join(codexHome, CHROME_CLIENT_RELATIVE), | 170 | modulePath: join(codexHome, CHROME_CLIENT_RELATIVE), |
| @@ -178,7 +183,9 @@ scenario( · 12 unmodified lines | |||
| 178 | command: plugin.command, | 183 | command: plugin.command, |
| 179 | args: [...plugin.args], | 184 | args: [...plugin.args], |
| 180 | ...(plugin.cwd === undefined ? {} : { cwd: plugin.cwd }), | 185 | ...(plugin.cwd === undefined ? {} : { cwd: plugin.cwd }), |
| 181 | ...(plugin.env === undefined ? {} : { env: { ...plugin.env } }), | 186 | // Static, not `env`: CODEX_HOME is a machine path the scanner |
| 187 | // resolved, so it must not become a credential to type. | ||
| 188 | ...(plugin.env === undefined ? {} : { staticEnv: { ...plugin.env } }), | ||
| 182 | ...(plugin.appServer === undefined | 189 | ...(plugin.appServer === undefined |
| 183 | ? {} | 190 | ? {} |
| 184 | : { appServer: { server: plugin.appServer.server } }), | 191 | : { appServer: { server: plugin.appServer.server } }), |
| @@ -200,6 +207,12 @@ scenario( · 15 unmodified lines | |||
| 200 | connections.map((connection) => String(connection.name)), | 207 | connections.map((connection) => String(connection.name)), |
| 201 | `${slug} auto-connected`, | 208 | `${slug} auto-connected`, |
| 202 | ).toContain("default"); | 209 | ).toContain("default"); |
| 210 | // Nothing to configure: the connection carries no credential, so | ||
| 211 | // the person is never shown a field for a path we already know. | ||
| 212 | expect( | ||
| 213 | connections.map((connection) => String(connection.template)), | ||
| 214 | `${slug} needs no credential`, | ||
| 215 | ).toEqual(["none"]); | ||
| 203 | 216 | ||
| 204 | const tools = yield* client.tools.list({ query: { integration: slug } }); | 217 | const tools = yield* client.tools.list({ query: { integration: slug } }); |
| 205 | const names = tools.map((tool) => tool.name); | 218 | const names = tools.map((tool) => tool.name); |
| @@ -0,0 +1,265 @@ | |||
| 1 | // The desktop system-browser OAuth flow: the renderer cannot receive a | ||
| 2 | // postMessage from the user's external browser, so it asks the local server | ||
| 3 | // for the result at `/api/oauth/await/:sessionId`. Guarantee under test: the | ||
| 4 | // server LONG-POLLS that route — a request issued while the flow is still in | ||
| 5 | // flight is held open and answered the instant the provider callback | ||
| 6 | // completes, instead of answered `null` (which made the renderer wait up to a | ||
| 7 | // full 1s poll tick with the result already sitting in memory). | ||
| 8 | // | ||
| 9 | // The journey, against a real `executor web` boot: | ||
| 10 | // | ||
| 11 | // 1. `oauth.start` a connect → the await sessionId (= `started.state`). | ||
| 12 | // 2. Issue an await BEFORE the provider callback. It must be held open — a | ||
| 13 | // pre-long-poll server answers `null` immediately, which this scenario | ||
| 14 | // rejects explicitly. A SECOND await for the same session must answer | ||
| 15 | // `null` instantly instead of holding: held waiters are capped at one | ||
| 16 | // per session, so an unpatched 1s-interval client degrades to exactly | ||
| 17 | // the old poll behavior instead of stacking held connections. | ||
| 18 | // 3. Complete the provider consent and land the REAL `/api/oauth/callback` | ||
| 19 | // redirect (the path that publishes the result and wakes waiters). | ||
| 20 | // 4. The held request resolves promptly (well under the old 1s poll tick) | ||
| 21 | // and carries the ok result — single-consumer delivery of the one-shot | ||
| 22 | // result. | ||
| 23 | import { randomBytes } from "node:crypto"; | ||
| 24 | import { writeFileSync } from "node:fs"; | ||
| 25 | import { join } from "node:path"; | ||
| 26 | |||
| 27 | import { expect } from "@effect/vitest"; | ||
| 28 | import { Effect } from "effect"; | ||
| 29 | import { HttpApiClient } from "effect/unstable/httpapi"; | ||
| 30 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 31 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 32 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | ||
| 33 | import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | ||
| 34 | import { | ||
| 35 | AuthTemplateSlug, | ||
| 36 | ConnectionName, | ||
| 37 | IntegrationSlug, | ||
| 38 | OAuthClientSlug, | ||
| 39 | } from "@executor-js/sdk/shared"; | ||
| 40 | import { serveOAuthTestServer } from "@executor-js/sdk/testing"; | ||
| 41 | |||
| 42 | import { scenario } from "../src/scenario"; | ||
| 43 | import { Cli, RunDir } from "../src/services"; | ||
| 44 | import { withLocalServer, type ServerHandle } from "./local-server"; | ||
| 45 | |||
| 46 | const api = composePluginApi([mcpHttpPlugin()] as const); | ||
| 47 | |||
| 48 | const name = ConnectionName.make("default"); | ||
| 49 | const template = AuthTemplateSlug.make("oauth2"); | ||
| 50 | |||
| 51 | // The old server answered a pending await with `null` immediately; the new | ||
| 52 | // one holds it (up to 25s per hold). Observing "still unresolved" after this | ||
| 53 | // window separates the two deterministically: a holding server cannot answer | ||
| 54 | // within it, and an answer within it IS the regression. | ||
| 55 | const HELD_OBSERVATION_MS = 600; | ||
| 56 | |||
| 57 | // The renderer used to learn about completion up to a full poll tick (1s) | ||
| 58 | // after the callback. The held request must beat that with margin for CI | ||
| 59 | // jitter. | ||
| 60 | const PROMPT_RESOLUTION_BUDGET_MS = 900; | ||
| 61 | |||
| 62 | const apiClient = (server: ServerHandle) => | ||
| 63 | HttpApiClient.make(api, { | ||
| 64 | baseUrl: new URL("/api", server.origin).toString(), | ||
| 65 | transformClient: HttpClient.mapRequest((request) => | ||
| 66 | HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), | ||
| 67 | ), | ||
| 68 | }).pipe(Effect.provide(FetchHttpClient.layer)); | ||
| 69 | |||
| 70 | interface AwaitAnswer { | ||
| 71 | /** When the server answered (response headers received). */ | ||
| 72 | readonly settledAt: number; | ||
| 73 | readonly status: number; | ||
| 74 | readonly body: unknown; | ||
| 75 | } | ||
| 76 | |||
| 77 | /** One `/api/oauth/await/:sessionId` request, exactly as the desktop renderer | ||
| 78 | * issues it (bearer-gated GET). The promise settles only when the server | ||
| 79 | * answers — which is the behavior under test. */ | ||
| 80 | const issueAwait = (server: ServerHandle, sessionId: string): Promise<AwaitAnswer> => | ||
| 81 | fetch(`${server.origin}/api/oauth/await/${encodeURIComponent(sessionId)}`, { | ||
| 82 | headers: { authorization: `Bearer ${server.token}` }, | ||
| 83 | }).then(async (response) => { | ||
| 84 | const settledAt = Date.now(); | ||
| 85 | return { settledAt, status: response.status, body: (await response.json()) as unknown }; | ||
| 86 | }); | ||
| 87 | |||
| 88 | /** Drive the test AS's consent (authorize → login) and return the app | ||
| 89 | * callback URL the provider redirects to — carrying code + state. Unlike the | ||
| 90 | * durability scenario this does NOT redeem the code by hand: landing the real | ||
| 91 | * callback is the exact path that publishes the result and wakes waiters. */ | ||
| 92 | const resolveProviderConsent = (authorizationUrl: string) => | ||
| 93 | Effect.promise(async () => { | ||
| 94 | const authorize = await fetch(authorizationUrl, { redirect: "manual" }); | ||
| 95 | const loginUrl = authorize.headers.get("location"); | ||
| 96 | if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`); | ||
| 97 | const login = await fetch(loginUrl, { | ||
| 98 | method: "POST", | ||
| 99 | headers: { authorization: `Basic ${Buffer.from("alice:password").toString("base64")}` }, | ||
| 100 | redirect: "manual", | ||
| 101 | }); | ||
| 102 | const callbackUrl = login.headers.get("location"); | ||
| 103 | if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`); | ||
| 104 | return callbackUrl; | ||
| 105 | }); | ||
| 106 | |||
| 107 | scenario( | ||
| 108 | "Local · a held OAuth await answers the moment the provider callback lands", | ||
| 109 | {}, | ||
| 110 | Effect.scoped( | ||
| 111 | Effect.gen(function* () { | ||
| 112 | const cli = yield* Cli; | ||
| 113 | const runDir = yield* RunDir; | ||
| 114 | |||
| 115 | const oauth = yield* serveOAuthTestServer({ scopes: ["mcp.read"] }); | ||
| 116 | // A real MCP upstream that accepts the AS's bearers, so the connect is a | ||
| 117 | // genuine end-to-end flow, not a stub handshake. | ||
| 118 | const mcp = yield* serveMcpServer(() => makeEchoMcpServer({ name: "longpoll-mcp" }), { | ||
| 119 | auth: { | ||
| 120 | validateAuthorization: (authorization) => oauth.acceptsAuthorizationHeader(authorization), | ||
| 121 | }, | ||
| 122 | }); | ||
| 123 | |||
| 124 | const suffix = randomBytes(4).toString("hex"); | ||
| 125 | const slug = IntegrationSlug.make(`oauth-longpoll-${suffix}`); | ||
| 126 | const clientSlug = OAuthClientSlug.make(`oauth-longpoll-client-${suffix}`); | ||
| 127 | |||
| 128 | yield* withLocalServer(cli, runDir, (server) => | ||
| 129 | Effect.gen(function* () { | ||
| 130 | const client = yield* apiClient(server); | ||
| 131 | |||
| 132 | yield* client.mcp.addServer({ | ||
| 133 | payload: { | ||
| 134 | transport: "remote", | ||
| 135 | name: "Longpoll MCP", | ||
| 136 | endpoint: mcp.url, | ||
| 137 | slug: String(slug), | ||
| 138 | authenticationTemplate: [{ kind: "oauth2" }], | ||
| 139 | }, | ||
| 140 | }); | ||
| 141 | yield* client.oauth.createClient({ | ||
| 142 | payload: { | ||
| 143 | owner: "org", | ||
| 144 | slug: clientSlug, | ||
| 145 | grant: "authorization_code", | ||
| 146 | authorizationUrl: oauth.authorizationEndpoint, | ||
| 147 | tokenUrl: oauth.tokenEndpoint, | ||
| 148 | clientId: "test-client", | ||
| 149 | clientSecret: "test-secret", | ||
| 150 | resource: oauth.mcpResourceUrl, | ||
| 151 | originIntegration: slug, | ||
| 152 | }, | ||
| 153 | }); | ||
| 154 | |||
| 155 | const started = yield* client.oauth.start({ | ||
| 156 | payload: { | ||
| 157 | client: clientSlug, | ||
| 158 | clientOwner: "org", | ||
| 159 | owner: "org", | ||
| 160 | name, | ||
| 161 | integration: slug, | ||
| 162 | template, | ||
| 163 | }, | ||
| 164 | }); | ||
| 165 | expect(started.status, "oauth.start redirects to the authorization server").toBe( | ||
| 166 | "redirect", | ||
| 167 | ); | ||
| 168 | if (started.status !== "redirect") return yield* Effect.die("no redirect"); | ||
| 169 | // The await sessionId is the start state — exactly what the desktop | ||
| 170 | // renderer passes to openOAuthSystemBrowser. | ||
| 171 | const sessionId = started.state; | ||
| 172 | |||
| 173 | // -- 2. Await BEFORE the callback: the request must be held. ------- | ||
| 174 | const heldAwait = issueAwait(server, sessionId); | ||
| 175 | const observed = yield* Effect.promise(() => | ||
| 176 | Promise.race([ | ||
| 177 | heldAwait.then(() => "answered" as const), | ||
| 178 | new Promise<"held">((resolve) => | ||
| 179 | setTimeout(() => resolve("held"), HELD_OBSERVATION_MS), | ||
| 180 | ), | ||
| 181 | ]), | ||
| 182 | ); | ||
| 183 | expect( | ||
| 184 | observed, | ||
| 185 | "an await issued mid-flow is held open, not answered null immediately (the pre-long-poll behavior)", | ||
| 186 | ).toBe("held"); | ||
| 187 | |||
| 188 | // A second await while one is already held: over the one-per-session | ||
| 189 | // cap, so it answers null instantly — the pre-long-poll behavior an | ||
| 190 | // unpatched 1s-interval client relies on — instead of stacking a | ||
| 191 | // held connection. | ||
| 192 | const overCap = yield* Effect.promise(() => | ||
| 193 | Promise.race([ | ||
| 194 | issueAwait(server, sessionId), | ||
| 195 | new Promise<"held">((resolve) => | ||
| 196 | setTimeout(() => resolve("held"), HELD_OBSERVATION_MS), | ||
| 197 | ), | ||
| 198 | ]), | ||
| 199 | ); | ||
| 200 | expect(overCap, "an over-cap await answers immediately instead of holding").not.toBe( | ||
| 201 | "held", | ||
| 202 | ); | ||
| 203 | if (overCap === "held") return yield* Effect.die("over-cap await was held"); | ||
| 204 | expect(overCap.status, "an over-cap await answers 200 like an immediate poll").toBe(200); | ||
| 205 | expect( | ||
| 206 | overCap.body, | ||
| 207 | "an over-cap await answers null — still pending — exactly like the old server", | ||
| 208 | ).toBeNull(); | ||
| 209 | |||
| 210 | // -- 3. Complete consent and land the real callback redirect. ------ | ||
| 211 | const redirected = yield* resolveProviderConsent(started.authorizationUrl); | ||
| 212 | // The harness boots `executor web --port 0` (OS-assigned), and the | ||
| 213 | // server builds the redirect URI from the CONFIGURED port — so the | ||
| 214 | // redirect reads `127.0.0.1:0`. Land the same callback path + query | ||
| 215 | // (code + state, the cryptographic gate) on the server's real bound | ||
| 216 | // origin; it is the same server and the same handler. | ||
| 217 | const callbackUrl = new URL(redirected); | ||
| 218 | expect( | ||
| 219 | callbackUrl.pathname, | ||
| 220 | "the provider redirects back to this server's OAuth callback", | ||
| 221 | ).toBe("/api/oauth/callback"); | ||
| 222 | const callbackStartedAt = Date.now(); | ||
| 223 | const callback = yield* Effect.promise(() => | ||
| 224 | fetch(`${server.origin}${callbackUrl.pathname}${callbackUrl.search}`), | ||
| 225 | ); | ||
| 226 | const callbackHtml = yield* Effect.promise(() => callback.text()); | ||
| 227 | const callbackCompletedAt = Date.now(); | ||
| 228 | expect(callback.status, "the OAuth callback succeeds").toBe(200); | ||
| 229 | expect(callbackHtml, "the callback page reports the connect succeeded").toContain( | ||
| 230 | "Connected", | ||
| 231 | ); | ||
| 232 | |||
| 233 | // -- 4. The held request answers promptly with the one-shot result. | ||
| 234 | const answer = yield* Effect.promise(() => heldAwait); | ||
| 235 | expect(answer.status, "a held await answers 200 like an immediate poll").toBe(200); | ||
| 236 | expect( | ||
| 237 | answer.body, | ||
| 238 | "the held await receives the one-shot result the callback published", | ||
| 239 | ).toMatchObject({ ok: true, sessionId }); | ||
| 240 | |||
| 241 | const sinceCallback = answer.settledAt - callbackCompletedAt; | ||
| 242 | // Console output is swallowed by the runner, so park the measured | ||
| 243 | // timings in the run dir as reviewable evidence. | ||
| 244 | writeFileSync( | ||
| 245 | join(runDir, "await-timing.json"), | ||
| 246 | JSON.stringify( | ||
| 247 | { | ||
| 248 | heldObservationMs: HELD_OBSERVATION_MS, | ||
| 249 | callbackHandlingMs: callbackCompletedAt - callbackStartedAt, | ||
| 250 | awaitsSettledAfterCallbackMs: sinceCallback, | ||
| 251 | budgetMs: PROMPT_RESOLUTION_BUDGET_MS, | ||
| 252 | }, | ||
| 253 | null, | ||
| 254 | 2, | ||
| 255 | ), | ||
| 256 | ); | ||
| 257 | expect( | ||
| 258 | sinceCallback, | ||
| 259 | "held awaits resolve promptly after the callback — well under the old 1s poll tick", | ||
| 260 | ).toBeLessThan(PROMPT_RESOLUTION_BUDGET_MS); | ||
| 261 | }), | ||
| 262 | ); | ||
| 263 | }), | ||
| 264 | ), | ||
| 265 | ); | ||
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/e2e", | 2 | "name": "@executor-js/e2e", |
| 3 | "version": "0.0.44", | 3 | "version": "0.0.46", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "scripts": { | 6 | "scripts": { |
| @@ -46,7 +46,7 @@ scenario( · 45 unmodified lines | |||
| 46 | }); | 46 | }); |
| 47 | 47 | ||
| 48 | await step("The probe seeded the detected method", async () => { | 48 | await step("The probe seeded the detected method", async () => { |
| 49 | await page.getByText("Method 1 · Detected").waitFor(); | 49 | await page.getByText("No authentication · Detected").waitFor(); |
| 50 | }); | 50 | }); |
| 51 | 51 | ||
| 52 | await step("Declare an API key method alongside it", async () => { | 52 | await step("Declare an API key method alongside it", async () => { |
| @@ -26,7 +26,7 @@ import { · 25 unmodified lines | |||
| 26 | 26 | ||
| 27 | import { scenario } from "../src/scenario"; | 27 | import { scenario } from "../src/scenario"; |
| 28 | import { Api, Browser, Target } from "../src/services"; | 28 | import { Api, Browser, Target } from "../src/services"; |
| 29 | import { clickToReveal, visit } from "../src/surfaces/browser"; | 29 | import { visit } from "../src/surfaces/browser"; |
| 30 | 30 | ||
| 31 | const api = composePluginApi([openApiHttpPlugin()] as const); | 31 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 32 | 32 | ||
| @@ -181,35 +181,10 @@ scenario( · 148 unmodified lines | |||
| 181 | const client = yield* makeApiClient(api, identity); | 181 | const client = yield* makeApiClient(api, identity); |
| 182 | 182 | ||
| 183 | yield* browser.session(identity, async ({ page, step }) => { | 183 | yield* browser.session(identity, async ({ page, step }) => { |
| 184 | await step("Open the Google integration catalog", async () => { | 184 | await step("Open the integrations registry browser", async () => { |
| 185 | await visit(page, "/integrations"); | 185 | await visit(page, "/integrations/browse"); |
| 186 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | 186 | await page.getByPlaceholder(/Search integrations, or paste a URL/).waitFor(); |
| 187 | await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); | ||
| 188 | }); | 187 | }); |
| 189 | |||
| 190 | const services = [ | ||
| 191 | "Google Calendar", | ||
| 192 | "Google Meet", | ||
| 193 | "Gmail", | ||
| 194 | "Google Sheets", | ||
| 195 | "Google Drive", | ||
| 196 | "Google Docs", | ||
| 197 | "Google Slides", | ||
| 198 | "Google Forms", | ||
| 199 | "Google Tasks", | ||
| 200 | "Google People", | ||
| 201 | "Google Photos Library", | ||
| 202 | "Google Photos Picker", | ||
| 203 | "Google Search Console", | ||
| 204 | ] as const; | ||
| 205 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | ||
| 206 | const search = dialog.getByPlaceholder(/Search or paste a URL/); | ||
| 207 | for (const service of services) { | ||
| 208 | await step(`${service} is available to connect`, async () => { | ||
| 209 | await search.fill(service); | ||
| 210 | await dialog.getByRole("link", { name: new RegExp(`^${service}\\b`) }).waitFor(); | ||
| 211 | }); | ||
| 212 | } | ||
| 213 | }); | 188 | }); |
| 214 | 189 | ||
| 215 | // The Executor-owned Google app is withheld from every listing: it is no | 190 | // The Executor-owned Google app is withheld from every listing: it is no |
| @@ -117,24 +117,21 @@ const createGoogleEmulator = Effect.gen(function* () { · 116 unmodified lines | |||
| 117 | const addGooglePresetFromCatalog = ( | 117 | const addGooglePresetFromCatalog = ( |
| 118 | browser: BrowserSurface, | 118 | browser: BrowserSurface, |
| 119 | identity: Identity, | 119 | identity: Identity, |
| 120 | presetId: string, | ||
| 120 | presetName: string, | 121 | presetName: string, |
| 121 | slug: string, | 122 | slug: string, |
| 122 | ) => | 123 | ) => |
| 123 | browser.session(identity, async ({ page, step }) => { | 124 | browser.session(identity, async ({ page, step }) => { |
| 124 | await step(`Open ${presetName} from the connect catalog`, async () => { | 125 | await step(`Open the ${presetName} preset's add flow`, async () => { |
| 125 | await page.goto("/integrations", { waitUntil: "networkidle" }); | 126 | // The connect dialog became the full-page picker, and registry-listed |
| 126 | await page | 127 | // provider presets no longer render cards there — the preset deep link |
| 127 | .getByRole("button", { name: /Connect/ }) | 128 | // is the stable route to a preset-configured add flow. |
| 128 | .first() | 129 | await page.goto(`/integrations/add/openapi?preset=${presetId}`, { |
| 129 | .click(); | 130 | waitUntil: "networkidle", |
| 130 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | 131 | }); |
| 131 | await dialog.waitFor(); | ||
| 132 | await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); | ||
| 133 | await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click(); | ||
| 134 | }); | 132 | }); |
| 135 | 133 | ||
| 136 | await step(`Add the ${presetName} integration`, async () => { | 134 | await step(`Add the ${presetName} integration`, async () => { |
| 137 | await page.waitForURL(/\/integrations\/add\/openapi/); | ||
| 138 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | 135 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); |
| 139 | const button = page.getByRole("button", { name: "Add integration" }); | 136 | const button = page.getByRole("button", { name: "Add integration" }); |
| 140 | await button.waitFor({ timeout: 120_000 }); | 137 | await button.waitFor({ timeout: 120_000 }); |
| @@ -218,7 +215,7 @@ scenario( · 77 unmodified lines | |||
| 218 | 215 | ||
| 219 | yield* Effect.ensuring( | 216 | yield* Effect.ensuring( |
| 220 | Effect.gen(function* () { | 217 | Effect.gen(function* () { |
| 221 | yield* addGooglePresetFromCatalog(browser, identity, "Gmail", String(slug)); | 218 | yield* addGooglePresetFromCatalog(browser, identity, "google-gmail", "Gmail", String(slug)); |
| 222 | 219 | ||
| 223 | const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); | 220 | const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); |
| 224 | expect(stored?.operation, "the Gmail preset declares its labels probe").toBe( | 221 | expect(stored?.operation, "the Gmail preset declares its labels probe").toBe( |
| @@ -17,13 +17,15 @@ import { createEmulatorInstance } from "../src/emulator-instance"; · 16 unmodified lines | |||
| 17 | import { scenario } from "../src/scenario"; | 17 | import { scenario } from "../src/scenario"; |
| 18 | import { Api, Browser, Target } from "../src/services"; | 18 | import { Api, Browser, Target } from "../src/services"; |
| 19 | import type { Identity, Target as TargetShape } from "../src/target"; | 19 | import type { Identity, Target as TargetShape } from "../src/target"; |
| 20 | import { type BrowserSurface, clickToReveal, visit } from "../src/surfaces/browser"; | 20 | import { type BrowserSurface, visit } from "../src/surfaces/browser"; |
| 21 | 21 | ||
| 22 | const api = composePluginApi([openApiHttpPlugin()] as const); | 22 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 23 | type Client = HttpApiClient.ForApi<typeof api>; | 23 | type Client = HttpApiClient.ForApi<typeof api>; |
| 24 | const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2"); | 24 | const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2"); |
| 25 | const CONNECTION = ConnectionName.make("main"); | 25 | const CONNECTION = ConnectionName.make("main"); |
| 26 | const GOOGLE_EMULATOR_ACCOUNT_EMAIL = "testuser@gmail.com"; | 26 | const GOOGLE_EMULATOR_ACCOUNT_EMAIL = "testuser@gmail.com"; |
| 27 | const googleDiscoveryUrl = (service: string, version: string) => | ||
| 28 | `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`; | ||
| 27 | 29 | ||
| 28 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | 30 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; |
| 29 | 31 | ||
| @@ -79,19 +81,18 @@ const createGoogleEmulator = Effect.gen(function* () { · 49 unmodified lines | |||
| 79 | return { client, baseUrl }; | 81 | return { client, baseUrl }; |
| 80 | }); | 82 | }); |
| 81 | 83 | ||
| 82 | const addGooglePresetFromCatalog = ( | 84 | const addGooglePresetFromDirectUrl = ( |
| 83 | browser: BrowserSurface, | 85 | browser: BrowserSurface, |
| 84 | identity: Identity, | 86 | identity: Identity, |
| 85 | presetName: string, | 87 | presetName: string, |
| 88 | presetId: string, | ||
| 89 | presetUrl: string, | ||
| 86 | slug: string, | 90 | slug: string, |
| 87 | ) => | 91 | ) => |
| 88 | browser.session(identity, async ({ page, step }) => { | 92 | browser.session(identity, async ({ page, step }) => { |
| 89 | await step(`Open ${presetName} from the connect catalog`, async () => { | 93 | await step(`Open the ${presetName} preset add flow directly`, async () => { |
| 90 | await visit(page, "/integrations"); | 94 | const search = new URLSearchParams({ preset: presetId, url: presetUrl }); |
| 91 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | 95 | await visit(page, `/integrations/add/openapi?${search}`); |
| 92 | await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); | ||
| 93 | await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); | ||
| 94 | await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click(); | ||
| 95 | }); | 96 | }); |
| 96 | 97 | ||
| 97 | await step(`Add the ${presetName} integration`, async () => { | 98 | await step(`Add the ${presetName} integration`, async () => { |
| @@ -201,6 +202,8 @@ scenario( · 103 unmodified lines | |||
| 201 | const rows = [ | 202 | const rows = [ |
| 202 | { | 203 | { |
| 203 | presetName: "Google Calendar", | 204 | presetName: "Google Calendar", |
| 205 | presetId: "google-calendar", | ||
| 206 | presetUrl: googleDiscoveryUrl("calendar", "v3"), | ||
| 204 | slug: IntegrationSlug.make("google_calendar"), | 207 | slug: IntegrationSlug.make("google_calendar"), |
| 205 | oauthClient: OAuthClientSlug.make(unique("google_calendar_oauth")), | 208 | oauthClient: OAuthClientSlug.make(unique("google_calendar_oauth")), |
| 206 | expectedHealthOperation: "calendar.calendarList.list", | 209 | expectedHealthOperation: "calendar.calendarList.list", |
| @@ -209,6 +212,8 @@ scenario( · 2 unmodified lines | |||
| 209 | }, | 212 | }, |
| 210 | { | 213 | { |
| 211 | presetName: "Gmail", | 214 | presetName: "Gmail", |
| 215 | presetId: "google-gmail", | ||
| 216 | presetUrl: googleDiscoveryUrl("gmail", "v1"), | ||
| 212 | slug: IntegrationSlug.make("google_gmail"), | 217 | slug: IntegrationSlug.make("google_gmail"), |
| 213 | oauthClient: OAuthClientSlug.make(unique("google_gmail_oauth")), | 218 | oauthClient: OAuthClientSlug.make(unique("google_gmail_oauth")), |
| 214 | expectedHealthOperation: "gmail.users.labels.list", | 219 | expectedHealthOperation: "gmail.users.labels.list", |
| @@ -220,7 +225,14 @@ scenario( · 5 unmodified lines | |||
| 220 | yield* Effect.ensuring( | 225 | yield* Effect.ensuring( |
| 221 | Effect.gen(function* () { | 226 | Effect.gen(function* () { |
| 222 | for (const row of rows) { | 227 | for (const row of rows) { |
| 223 | yield* addGooglePresetFromCatalog(browser, identity, row.presetName, String(row.slug)); | 228 | yield* addGooglePresetFromDirectUrl( |
| 229 | browser, | ||
| 230 | identity, | ||
| 231 | row.presetName, | ||
| 232 | row.presetId, | ||
| 233 | row.presetUrl, | ||
| 234 | String(row.slug), | ||
| 235 | ); | ||
| 224 | 236 | ||
| 225 | const stored = yield* client.integrations.healthCheckGet({ params: { slug: row.slug } }); | 237 | const stored = yield* client.integrations.healthCheckGet({ params: { slug: row.slug } }); |
| 226 | expect(stored?.operation, `${row.presetName} stored health check`).toBe( | 238 | expect(stored?.operation, `${row.presetName} stored health check`).toBe( |
| @@ -324,7 +336,14 @@ scenario( · 97 unmodified lines | |||
| 324 | 336 | ||
| 325 | yield* Effect.ensuring( | 337 | yield* Effect.ensuring( |
| 326 | Effect.gen(function* () { | 338 | Effect.gen(function* () { |
| 327 | yield* addGooglePresetFromCatalog(browser, identity, "Google Sheets", String(slug)); | 339 | yield* addGooglePresetFromDirectUrl( |
| 340 | browser, | ||
| 341 | identity, | ||
| 342 | "Google Sheets", | ||
| 343 | "google-sheets", | ||
| 344 | googleDiscoveryUrl("sheets", "v4"), | ||
| 345 | String(slug), | ||
| 346 | ); | ||
| 328 | 347 | ||
| 329 | const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); | 348 | const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); |
| 330 | expect(stored, "Google Sheets catalog preset declares no health check").toBeNull(); | 349 | expect(stored, "Google Sheets catalog preset declares no health check").toBeNull(); |
| @@ -3,10 +3,15 @@ import { Effect } from "effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { scenario } from "../src/scenario"; | 4 | import { scenario } from "../src/scenario"; |
| 5 | import { Browser, Target } from "../src/services"; | 5 | import { Browser, Target } from "../src/services"; |
| 6 | import { clickToReveal, visit } from "../src/surfaces/browser"; | 6 | import { visit } from "../src/surfaces/browser"; |
| 7 | 7 | ||
| 8 | const librarySpecUrl = "https://integrations.sh/specs/google/google-photos-library.json"; | ||
| 9 | const pickerSpecUrl = "https://integrations.sh/specs/google/google-photos-picker.json"; | ||
| 10 | |||
| 11 | // Photos Library and Photos Picker are independent registry products. The | ||
| 12 | // picker must preserve that distinction all the way into the selected add flow. | ||
| 8 | scenario( | 13 | scenario( |
| 9 | "Google Photos: separated catalog presets open a Photos service add flow", | 14 | "Google Photos products are separate registry cards with separate add flows", |
| 10 | {}, | 15 | {}, |
| 11 | Effect.gen(function* () { | 16 | Effect.gen(function* () { |
| 12 | const target = yield* Target; | 17 | const target = yield* Target; |
| @@ -14,30 +19,100 @@ scenario( · 1 unmodified line | |||
| 14 | const identity = yield* target.newIdentity(); | 19 | const identity = yield* target.newIdentity(); |
| 15 | 20 | ||
| 16 | yield* browser.session(identity, async ({ page, step }) => { | 21 | yield* browser.session(identity, async ({ page, step }) => { |
| 17 | await step( | 22 | await step("Stub the two Google Photos registry products", async () => { |
| 18 | "Find the separated Google Photos presets from the integrations picker", | 23 | await page.route("https://integrations.sh/api/search*", (route) => |
| 19 | async () => { | 24 | route.fulfill({ |
| 20 | await visit(page, "/integrations"); | 25 | contentType: "application/json", |
| 21 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | 26 | headers: { "access-control-allow-origin": "*" }, |
| 22 | await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); | 27 | json: { |
| 23 | await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos"); | 28 | results: [ |
| 24 | await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).waitFor(); | 29 | { |
| 25 | await dialog.getByRole("link", { name: /^Google Photos Picker\b/ }).waitFor(); | 30 | domain: "photos.google.com", |
| 26 | }, | 31 | name: "Google Photos Library", |
| 27 | ); | 32 | description: "Create and manage app-created photos.", |
| 33 | kinds: ["openapi"], | ||
| 34 | url: "https://integrations.sh/photos.google.com/", | ||
| 35 | }, | ||
| 36 | { | ||
| 37 | domain: "photos.google.com", | ||
| 38 | name: "Google Photos Picker", | ||
| 39 | description: "Let people select photos for an app.", | ||
| 40 | kinds: ["openapi"], | ||
| 41 | url: "https://integrations.sh/photos.google.com/", | ||
| 42 | }, | ||
| 43 | ], | ||
| 44 | }, | ||
| 45 | }), | ||
| 46 | ); | ||
| 47 | await page.route("https://integrations.sh/api/photos.google.com/surface", (route) => | ||
| 48 | route.fulfill({ | ||
| 49 | contentType: "application/json", | ||
| 50 | headers: { "access-control-allow-origin": "*" }, | ||
| 51 | json: { | ||
| 52 | version: 3, | ||
| 53 | domain: "photos.google.com", | ||
| 54 | surfaces: [ | ||
| 55 | { type: "http", slug: "google-photos-library", spec: librarySpecUrl }, | ||
| 56 | { type: "http", slug: "google-photos-picker", spec: pickerSpecUrl }, | ||
| 57 | ], | ||
| 58 | }, | ||
| 59 | }), | ||
| 60 | ); | ||
| 61 | }); | ||
| 62 | |||
| 63 | await step("Search for Photos and see two separate product cards", async () => { | ||
| 64 | await visit(page, "/integrations/browse"); | ||
| 65 | await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("photos"); | ||
| 66 | const cards = page.getByTestId("catalog-photos.google.com-openapi"); | ||
| 67 | await cards.filter({ hasText: "Google Photos Library API" }).waitFor(); | ||
| 68 | await cards.filter({ hasText: "Google Photos Picker API" }).waitFor(); | ||
| 69 | }); | ||
| 28 | 70 | ||
| 29 | await step("Open the Google Photos Library add flow", async () => { | 71 | await step("Add Photos Library in place; the Picker card is untouched", async () => { |
| 30 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | 72 | await page |
| 31 | await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).click(); | 73 | .getByTestId("catalog-photos.google.com-openapi") |
| 32 | await page.waitForURL(/\/integrations\/add\/openapi/); | 74 | .filter({ hasText: "Google Photos Library API" }) |
| 33 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | 75 | .getByRole("button", { name: "Add Google Photos Library API" }) |
| 76 | .click(); | ||
| 77 | const libraryCard = page | ||
| 78 | .getByTestId("catalog-photos.google.com-openapi") | ||
| 79 | .filter({ hasText: "Google Photos Library API" }); | ||
| 80 | await libraryCard | ||
| 81 | .getByRole("link", { name: "View Google Photos Library API" }) | ||
| 82 | .waitFor({ timeout: 120_000 }); | ||
| 83 | expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/); | ||
| 84 | // Product identity survived: the LIBRARY slug became the namespace… | ||
| 85 | const href = await libraryCard | ||
| 86 | .getByRole("link", { name: "View Google Photos Library API" }) | ||
| 87 | .getAttribute("href"); | ||
| 88 | expect(href).toContain("/integrations/google_photos_library"); | ||
| 89 | // …and the Picker card, same domain and kind, still offers Add. | ||
| 90 | await page | ||
| 91 | .getByTestId("catalog-photos.google.com-openapi") | ||
| 92 | .filter({ hasText: "Google Photos Picker API" }) | ||
| 93 | .getByRole("button", { name: "Add Google Photos Picker API" }) | ||
| 94 | .waitFor(); | ||
| 34 | }); | 95 | }); |
| 35 | 96 | ||
| 36 | await step("The Library link carries the focused OpenAPI preset", async () => { | 97 | await step("Adding the SECOND same-kind card keeps its own identity", async () => { |
| 37 | const url = new URL(page.url()); | 98 | // The regression this guards: selection re-derived the surface by |
| 38 | expect(url.searchParams.get("preset")).toBe("google-photos-library"); | 99 | // KIND, so the second product of a kind inherited the first's slug, |
| 39 | expect(url.searchParams.get("url")).toContain("photoslibrary"); | 100 | // auth, and overrides. |
| 40 | await expect.poll(() => page.locator("textarea").inputValue()).toContain("photoslibrary"); | 101 | await page |
| 102 | .getByTestId("catalog-photos.google.com-openapi") | ||
| 103 | .filter({ hasText: "Google Photos Picker API" }) | ||
| 104 | .getByRole("button", { name: "Add Google Photos Picker API" }) | ||
| 105 | .click(); | ||
| 106 | const pickerCard = page | ||
| 107 | .getByTestId("catalog-photos.google.com-openapi") | ||
| 108 | .filter({ hasText: "Google Photos Picker API" }); | ||
| 109 | await pickerCard | ||
| 110 | .getByRole("link", { name: "View Google Photos Picker API" }) | ||
| 111 | .waitFor({ timeout: 120_000 }); | ||
| 112 | const href = await pickerCard | ||
| 113 | .getByRole("link", { name: "View Google Photos Picker API" }) | ||
| 114 | .getAttribute("href"); | ||
| 115 | expect(href).toContain("/integrations/google_photos_picker"); | ||
| 41 | }); | 116 | }); |
| 42 | }); | 117 | }); |
| 43 | }), | 118 | }), |
| @@ -1,77 +1,116 @@ | |||
| 1 | import { randomBytes } from "node:crypto"; | ||
| 2 | |||
| 1 | import { expect } from "@effect/vitest"; | 3 | import { expect } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 4 | import { Effect } from "effect"; |
| 5 | import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | ||
| 3 | 6 | ||
| 4 | import { scenario } from "../src/scenario"; | 7 | import { scenario } from "../src/scenario"; |
| 5 | import { Browser, Target } from "../src/services"; | 8 | import { Browser, Target } from "../src/services"; |
| 6 | import { clickToReveal, visit } from "../src/surfaces/browser"; | 9 | import { visit } from "../src/surfaces/browser"; |
| 7 | 10 | ||
| 8 | // The connect dialog's long-tail search goes to the public integrations.sh | 11 | // The picker's registry search goes to the public integrations.sh service from |
| 9 | // registry from the browser. CI must not depend on the live service, so both | 12 | // the browser. CI must not depend on the live service, so the search endpoint |
| 10 | // registry endpoints are fulfilled at the network layer here — including the | 13 | // is fulfilled at the network layer here — including the CORS header a real |
| 11 | // CORS header a real cross-origin browser call needs. | 14 | // cross-origin browser call needs. The MCP endpoints themselves are NOT |
| 15 | // stubbed: the quick add probes and registers server-side, so the working row | ||
| 16 | // points at a local fixture server and the broken row at a reserved-TLD host. | ||
| 12 | scenario( | 17 | scenario( |
| 13 | "Connect dialog: integrations.sh catalog search resolves into a prefilled add flow", | 18 | "Add page: a registry row adds in place, and falls back to the config screen", |
| 14 | {}, | 19 | {}, |
| 15 | Effect.gen(function* () { | 20 | Effect.scoped( |
| 16 | const target = yield* Target; | 21 | Effect.gen(function* () { |
| 17 | const browser = yield* Browser; | 22 | const target = yield* Target; |
| 18 | const identity = yield* target.newIdentity(); | 23 | const browser = yield* Browser; |
| 24 | // Unique per run — the derived namespace must not collide on targets | ||
| 25 | // whose identities share one tenant (selfhost admin). | ||
| 26 | const suffix = randomBytes(3).toString("hex"); | ||
| 27 | const server = yield* serveMcpServer(() => | ||
| 28 | makeGreetingMcpServer({ name: `catalog-quick-${suffix}` }), | ||
| 29 | ); | ||
| 30 | const identity = yield* target.newIdentity(); | ||
| 19 | 31 | ||
| 20 | yield* browser.session(identity, async ({ page, step }) => { | 32 | yield* browser.session(identity, async ({ page, step }) => { |
| 21 | await step("Stub the integrations.sh registry endpoints", async () => { | 33 | await step("Stub the integrations.sh search endpoint", async () => { |
| 22 | await page.route("https://integrations.sh/api/search*", (route) => | 34 | await page.route("https://integrations.sh/api/search*", (route) => |
| 23 | route.fulfill({ | 35 | route.fulfill({ |
| 24 | contentType: "application/json", | 36 | contentType: "application/json", |
| 25 | headers: { "access-control-allow-origin": "*" }, | 37 | headers: { "access-control-allow-origin": "*" }, |
| 26 | json: { | 38 | json: { |
| 27 | results: [ | 39 | results: [ |
| 28 | { | 40 | { |
| 29 | domain: "todoist.com", | 41 | domain: "todoist.com", |
| 30 | name: "todoist.com", | 42 | name: "todoist.com", |
| 31 | description: "Tasks, projects, and collaboration.", | 43 | description: "Tasks, projects, and collaboration.", |
| 32 | kinds: ["mcp", "cli"], | 44 | kinds: ["mcp", "cli"], |
| 33 | url: "https://integrations.sh/todoist.com/", | 45 | surfaces: [ |
| 34 | }, | 46 | { kind: "mcp", slug: `todoist-${suffix}`, url: server.endpoint }, |
| 35 | ], | 47 | { kind: "cli", slug: "todoist-cli" }, |
| 36 | }, | 48 | ], |
| 37 | }), | 49 | }, |
| 38 | ); | 50 | { |
| 39 | await page.route("https://integrations.sh/api/todoist.com/surface", (route) => | 51 | domain: "deadserver.com", |
| 40 | route.fulfill({ | 52 | name: "deadserver.com", |
| 41 | contentType: "application/json", | 53 | description: "A vendor whose MCP endpoint is gone.", |
| 42 | headers: { "access-control-allow-origin": "*" }, | 54 | kinds: ["mcp"], |
| 43 | json: { | 55 | surfaces: [ |
| 44 | version: 3, | 56 | { kind: "mcp", slug: "deadserver", url: "https://mcp.notareal.invalid/mcp" }, |
| 45 | domain: "todoist.com", | 57 | ], |
| 46 | surfaces: [ | 58 | }, |
| 47 | { type: "mcp", url: "https://ai.todoist.net/mcp", slug: "todoist" }, | 59 | ], |
| 48 | { type: "cli", slug: "todoist-cli" }, | 60 | }, |
| 49 | ], | 61 | }), |
| 50 | }, | 62 | ); |
| 51 | }), | 63 | }); |
| 52 | ); | ||
| 53 | }); | ||
| 54 | 64 | ||
| 55 | await step("Searching surfaces the catalog row under the presets", async () => { | 65 | await step("Searching surfaces the connectable rows, not the CLI", async () => { |
| 56 | await visit(page, "/integrations"); | 66 | await visit(page, "/integrations/browse"); |
| 57 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | 67 | await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist"); |
| 58 | await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); | 68 | await page.getByTestId(`catalog-todoist-${suffix}`).getByText("Todoist MCP").waitFor(); |
| 59 | await dialog.getByPlaceholder(/Search or paste a URL/).fill("todoist"); | 69 | await page.getByTestId("catalog-deadserver").waitFor(); |
| 60 | // The CLI-only surface is not offered; the connectable kind is. | 70 | expect(await page.getByTestId("catalog-todoist-cli").count()).toBe(0); |
| 61 | await dialog | 71 | }); |
| 62 | .getByRole("button", { name: /todoist\.com/ }) | 72 | |
| 63 | .getByText("MCP") | 73 | await step("Add registers in place; the card flips to View", async () => { |
| 64 | .waitFor(); | 74 | await page |
| 65 | }); | 75 | .getByTestId(`catalog-todoist-${suffix}`) |
| 76 | .getByRole("button", { name: "Add Todoist MCP" }) | ||
| 77 | .click(); | ||
| 78 | // Generous timeout: the quick add probes the fixture server and | ||
| 79 | // registers, all behind one click, and the dev server can queue | ||
| 80 | // under CI load. | ||
| 81 | await page | ||
| 82 | .getByTestId(`catalog-todoist-${suffix}`) | ||
| 83 | .getByRole("link", { name: "View Todoist MCP" }) | ||
| 84 | .waitFor({ timeout: 90_000 }); | ||
| 85 | // The whole point: the user never left the picker. | ||
| 86 | expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/); | ||
| 87 | }); | ||
| 88 | |||
| 89 | await step("View jumps to the integration's hub", async () => { | ||
| 90 | await page | ||
| 91 | .getByTestId(`catalog-todoist-${suffix}`) | ||
| 92 | .getByRole("link", { name: "View Todoist MCP" }) | ||
| 93 | .click(); | ||
| 94 | await page.waitForURL(new RegExp(`/integrations/todoist_${suffix}`)); | ||
| 95 | await page.goBack(); | ||
| 96 | await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist"); | ||
| 97 | await page.getByTestId("catalog-deadserver").waitFor(); | ||
| 98 | }); | ||
| 66 | 99 | ||
| 67 | await step("Picking the row lands on the MCP add flow, prefilled", async () => { | 100 | await step("An unreachable endpoint falls back to the config screen", async () => { |
| 68 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | 101 | await page |
| 69 | await dialog.getByRole("button", { name: /todoist\.com/ }).click(); | 102 | .getByTestId("catalog-deadserver") |
| 70 | await page.waitForURL(/\/integrations\/add\/mcp/); | 103 | .getByRole("button", { name: "Add Deadserver MCP" }) |
| 71 | const url = new URL(page.url()); | 104 | .click(); |
| 72 | expect(url.searchParams.get("url")).toBe("https://ai.todoist.net/mcp"); | 105 | // Quick add cannot register it, so the click lands on the add flow |
| 73 | expect(url.searchParams.get("namespace")).toBe("todoist"); | 106 | // prefilled with the same facts — where the probe failure renders |
| 107 | // with retry UX. | ||
| 108 | await page.waitForURL(/\/integrations\/add\/mcp/, { timeout: 90_000 }); | ||
| 109 | const url = new URL(page.url()); | ||
| 110 | expect(url.searchParams.get("url")).toBe("https://mcp.notareal.invalid/mcp"); | ||
| 111 | expect(url.searchParams.get("namespace")).toBe("deadserver"); | ||
| 112 | }); | ||
| 74 | }); | 113 | }); |
| 75 | }); | 114 | }), |
| 76 | }), | 115 | ), |
| 77 | ); | 116 | ); |
| @@ -40,7 +40,7 @@ scenario( · 39 unmodified lines | |||
| 40 | // The URL auto-probes (debounced); the method list appears once the | 40 | // The URL auto-probes (debounced); the method list appears once the |
| 41 | // probe lands — an open server seeds the detected no-auth method. | 41 | // probe lands — an open server seeds the detected no-auth method. |
| 42 | await page.getByText("How does this server authenticate?").waitFor(); | 42 | await page.getByText("How does this server authenticate?").waitFor(); |
| 43 | await page.getByText("Method 1 · Detected").waitFor(); | 43 | await page.getByText("No authentication · Detected").waitFor(); |
| 44 | }); | 44 | }); |
| 45 | 45 | ||
| 46 | await step("Add the integration", async () => { | 46 | await step("Add the integration", async () => { |
| @@ -1,11 +1,23 @@ | |||
| 1 | // Cross-target: the MCP surface — connect with fully headless OAuth (DCR → | 1 | // Cross-target: the MCP surface — connect with fully headless OAuth (DCR → |
| 2 | // consent → code → token) and run code in the sandbox, exactly as an MCP | 2 | // consent → code → token) and run code in the sandbox, exactly as an MCP |
| 3 | // client (Claude, Cursor, …) would. | 3 | // client (Claude, Cursor, …) would. |
| 4 | import { randomUUID } from "node:crypto"; | ||
| 5 | |||
| 4 | import { expect } from "@effect/vitest"; | 6 | import { expect } from "@effect/vitest"; |
| 5 | import { Effect } from "effect"; | 7 | import { Effect } from "effect"; |
| 8 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 6 | 9 | ||
| 7 | import { scenario } from "../src/scenario"; | 10 | import { scenario } from "../src/scenario"; |
| 8 | import { Mcp, Target } from "../src/services"; | 11 | import { Api, Mcp, Target } from "../src/services"; |
| 12 | |||
| 13 | const coreApi = composePluginApi([] as const); | ||
| 14 | |||
| 15 | /** The raw MCP tool result shape the fidelity scenarios assert against. */ | ||
| 16 | const rawResultOf = (result: { readonly raw: unknown }) => | ||
| 17 | result.raw as { | ||
| 18 | content?: ReadonlyArray<{ type: string; text?: string }>; | ||
| 19 | structuredContent?: Record<string, unknown>; | ||
| 20 | }; | ||
| 9 | 21 | ||
| 10 | scenario( | 22 | scenario( |
| 11 | "MCP · OAuth connect, then execute code in the sandbox", | 23 | "MCP · OAuth connect, then execute code in the sandbox", |
| @@ -24,6 +36,137 @@ scenario( · 12 unmodified lines | |||
| 24 | }), | 36 | }), |
| 25 | ); | 37 | ); |
| 26 | 38 | ||
| 39 | // The exact value a sandbox script returns, non-ASCII included. The server | ||
| 40 | // renders it into the text channel (pretty-printed JSON) and mirrors it into | ||
| 41 | // `structuredContent`; both must reach the client byte-identical — a client | ||
| 42 | // diffing retries or hashing results must never see the payload drift. | ||
| 43 | const structuredPayload = { | ||
| 44 | greeting: "héllo — こんにちは ✅", | ||
| 45 | emoji: "🚀", | ||
| 46 | values: [1, 2, 3], | ||
| 47 | nested: { ok: true, label: "Zoë" }, | ||
| 48 | }; | ||
| 49 | |||
| 50 | scenario( | ||
| 51 | "MCP · a structured return value reaches the client byte-identical in text and structuredContent", | ||
| 52 | {}, | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const target = yield* Target; | ||
| 55 | const mcp = yield* Mcp; | ||
| 56 | const identity = yield* target.newIdentity(); | ||
| 57 | const session = mcp.session(identity); | ||
| 58 | yield* session.listTools(); | ||
| 59 | |||
| 60 | const result = yield* session.call("execute", { | ||
| 61 | code: `return ${JSON.stringify(structuredPayload)};`, | ||
| 62 | }); | ||
| 63 | expect(result.ok, "the sandbox run completes without error").toBe(true); | ||
| 64 | |||
| 65 | const expectedText = JSON.stringify(structuredPayload, null, 2); | ||
| 66 | const raw = rawResultOf(result); | ||
| 67 | expect(raw.content?.length, "the result arrives as a single text block").toBe(1); | ||
| 68 | expect(raw.content?.[0]?.text, "the text channel carries the exact rendered value").toBe( | ||
| 69 | expectedText, | ||
| 70 | ); | ||
| 71 | expect(result.text, "the joined text content matches byte-for-byte").toBe(expectedText); | ||
| 72 | expect(raw.structuredContent, "structuredContent mirrors the exact returned value").toEqual({ | ||
| 73 | status: "completed", | ||
| 74 | result: structuredPayload, | ||
| 75 | logs: [], | ||
| 76 | }); | ||
| 77 | }), | ||
| 78 | ); | ||
| 79 | |||
| 80 | /** Sandbox code that runs ONE approval-gated call (the `policies.create` core | ||
| 81 | * tool gates itself via its `requiresApproval` annotation — same hermetic | ||
| 82 | * device as policy-tool-approval.test.ts) and then returns a deterministic | ||
| 83 | * payload. The pattern is unique-per-run and matches no real tool, so the | ||
| 84 | * created `block` rule is inert even if leaked. */ | ||
| 85 | const gatedThenReturnCode = (pattern: string, payload: unknown) => ` | ||
| 86 | await tools.executor.coreTools.policies.create({ | ||
| 87 | owner: "user", | ||
| 88 | pattern: ${JSON.stringify(pattern)}, | ||
| 89 | action: "block", | ||
| 90 | }); | ||
| 91 | return ${JSON.stringify(payload)}; | ||
| 92 | `; | ||
| 93 | |||
| 94 | scenario( | ||
| 95 | "MCP · a duplicate resume replays the identical settled result without re-running the tool", | ||
| 96 | {}, | ||
| 97 | Effect.gen(function* () { | ||
| 98 | const target = yield* Target; | ||
| 99 | const apiSurface = yield* Api; | ||
| 100 | const mcp = yield* Mcp; | ||
| 101 | const identity = yield* target.newIdentity(); | ||
| 102 | const client = yield* apiSurface.client(coreApi, identity); | ||
| 103 | const pattern = `mcp-resume-replay-${randomUUID().slice(0, 8)}.*`; | ||
| 104 | const replayPayload = { note: "resumed — résultat 完了 ✅", pattern }; | ||
| 105 | |||
| 106 | const cleanup = client.policies.list().pipe( | ||
| 107 | Effect.flatMap((list) => | ||
| 108 | Effect.forEach( | ||
| 109 | list.filter((p) => p.pattern === pattern), | ||
| 110 | (p) => | ||
| 111 | client.policies | ||
| 112 | .remove({ params: { policyId: p.id }, payload: { owner: "user" } }) | ||
| 113 | .pipe(Effect.ignore), | ||
| 114 | ), | ||
| 115 | ), | ||
| 116 | Effect.ignore, | ||
| 117 | ); | ||
| 118 | |||
| 119 | yield* Effect.gen(function* () { | ||
| 120 | const session = mcp.session(identity); | ||
| 121 | yield* session.listTools(); | ||
| 122 | |||
| 123 | const paused = yield* session.call("execute", { | ||
| 124 | code: gatedThenReturnCode(pattern, replayPayload), | ||
| 125 | }); | ||
| 126 | expect(paused.text, "the gated call pauses for approval").toContain("Execution paused"); | ||
| 127 | const match = /\bexecutionId:\s*(\S+)/.exec(paused.text); | ||
| 128 | expect(match, "the pause carries an executionId to resume").not.toBeNull(); | ||
| 129 | |||
| 130 | // MCP clients retry `resume` when a response is lost in transit, so the | ||
| 131 | // duplicate uses the exact same arguments as the first delivery. | ||
| 132 | const resumeArgs = { | ||
| 133 | executionId: match![1]!, | ||
| 134 | action: "accept", | ||
| 135 | content: JSON.stringify({}), | ||
| 136 | }; | ||
| 137 | |||
| 138 | const resumed = yield* session.call("resume", resumeArgs); | ||
| 139 | expect(resumed.ok, "the approved execution completes without error").toBe(true); | ||
| 140 | const expectedText = JSON.stringify(replayPayload, null, 2); | ||
| 141 | expect(resumed.text, "the resumed result carries the exact returned value").toBe( | ||
| 142 | expectedText, | ||
| 143 | ); | ||
| 144 | expect( | ||
| 145 | rawResultOf(resumed).structuredContent, | ||
| 146 | "the resumed structuredContent mirrors the exact returned value", | ||
| 147 | ).toEqual({ status: "completed", result: replayPayload, logs: [] }); | ||
| 148 | |||
| 149 | const replayed = yield* session.call("resume", resumeArgs); | ||
| 150 | expect(replayed.ok, "the duplicate resume succeeds instead of erroring").toBe(true); | ||
| 151 | expect(replayed.text, "the replayed text is byte-identical to the first delivery").toBe( | ||
| 152 | resumed.text, | ||
| 153 | ); | ||
| 154 | expect( | ||
| 155 | rawResultOf(replayed).structuredContent, | ||
| 156 | "the replayed structuredContent is identical to the first delivery", | ||
| 157 | ).toEqual(rawResultOf(resumed).structuredContent); | ||
| 158 | |||
| 159 | // The replay served the recorded outcome — the gated tool did not run a | ||
| 160 | // second time. | ||
| 161 | const afterReplay = yield* client.policies.list(); | ||
| 162 | expect( | ||
| 163 | afterReplay.filter((p) => p.pattern === pattern).length, | ||
| 164 | "the gated tool ran exactly once despite the duplicate resume", | ||
| 165 | ).toBe(1); | ||
| 166 | }).pipe(Effect.ensuring(cleanup)); | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | |||
| 27 | scenario( | 170 | scenario( |
| 28 | "MCP · a syntax error returns a descriptive message, not an opaque internal error", | 171 | "MCP · a syntax error returns a descriptive message, not an opaque internal error", |
| 29 | {}, | 172 | {}, |
| @@ -3,10 +3,16 @@ import { Effect } from "effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { scenario } from "../src/scenario"; | 4 | import { scenario } from "../src/scenario"; |
| 5 | import { Browser, Target } from "../src/services"; | 5 | import { Browser, Target } from "../src/services"; |
| 6 | import { clickToReveal, visit } from "../src/surfaces/browser"; | 6 | import { visit } from "../src/surfaces/browser"; |
| 7 | 7 | ||
| 8 | const gmailSpecUrl = "https://integrations.sh/specs/google/google-gmail.json"; | ||
| 9 | const outlookSpecUrl = "https://integrations.sh/specs/microsoft-graph/mail.json"; | ||
| 10 | |||
| 11 | // Provider products are ordinary integrations.sh results now. Stub both the | ||
| 12 | // search response and the per-domain surface documents so this scenario proves | ||
| 13 | // the full registry-card-to-add-flow journey without depending on the service. | ||
| 8 | scenario( | 14 | scenario( |
| 9 | "Provider catalog · Google and Microsoft services are OpenAPI presets", | 15 | "Google and Microsoft products are registry cards that resolve into prefilled add flows", |
| 10 | {}, | 16 | {}, |
| 11 | Effect.gen(function* () { | 17 | Effect.gen(function* () { |
| 12 | const target = yield* Target; | 18 | const target = yield* Target; |
| @@ -14,58 +20,91 @@ scenario( · 1 unmodified line | |||
| 14 | const identity = yield* target.newIdentity(); | 20 | const identity = yield* target.newIdentity(); |
| 15 | 21 | ||
| 16 | yield* browser.session(identity, async ({ page, step }) => { | 22 | yield* browser.session(identity, async ({ page, step }) => { |
| 17 | await step("Open the integrations picker from the sidebar", async () => { | 23 | await step("Stub the Google and Microsoft registry products", async () => { |
| 18 | await visit(page, "/policies"); | 24 | await page.route("https://integrations.sh/api/search*", (route) => |
| 19 | await clickToReveal( | 25 | route.fulfill({ |
| 20 | page | 26 | contentType: "application/json", |
| 21 | .getByRole("navigation") | 27 | headers: { "access-control-allow-origin": "*" }, |
| 22 | .getByRole("button", { name: "Browse integrations", exact: true }), | 28 | json: { |
| 23 | page.getByRole("dialog", { name: "Connect an integration" }), | 29 | results: [ |
| 30 | { | ||
| 31 | domain: "gmail.com", | ||
| 32 | name: "Gmail", | ||
| 33 | description: "Email from Google.", | ||
| 34 | kinds: ["openapi"], | ||
| 35 | url: "https://integrations.sh/gmail.com/", | ||
| 36 | }, | ||
| 37 | { | ||
| 38 | domain: "graph.microsoft.com", | ||
| 39 | name: "Outlook Mail", | ||
| 40 | description: "Email from Microsoft.", | ||
| 41 | kinds: ["openapi"], | ||
| 42 | url: "https://integrations.sh/graph.microsoft.com/", | ||
| 43 | }, | ||
| 44 | ], | ||
| 45 | }, | ||
| 46 | }), | ||
| 47 | ); | ||
| 48 | await page.route("https://integrations.sh/api/gmail.com/surface", (route) => | ||
| 49 | route.fulfill({ | ||
| 50 | contentType: "application/json", | ||
| 51 | headers: { "access-control-allow-origin": "*" }, | ||
| 52 | json: { | ||
| 53 | version: 3, | ||
| 54 | domain: "gmail.com", | ||
| 55 | surfaces: [{ type: "http", slug: "google-gmail", spec: gmailSpecUrl }], | ||
| 56 | }, | ||
| 57 | }), | ||
| 58 | ); | ||
| 59 | await page.route("https://integrations.sh/api/graph.microsoft.com/surface", (route) => | ||
| 60 | route.fulfill({ | ||
| 61 | contentType: "application/json", | ||
| 62 | headers: { "access-control-allow-origin": "*" }, | ||
| 63 | json: { | ||
| 64 | version: 3, | ||
| 65 | domain: "graph.microsoft.com", | ||
| 66 | surfaces: [{ type: "http", slug: "outlook-mail", spec: outlookSpecUrl }], | ||
| 67 | }, | ||
| 68 | }), | ||
| 24 | ); | 69 | ); |
| 25 | }); | 70 | }); |
| 26 | 71 | ||
| 27 | await step("The picker exposes OpenAPI plus provider service presets", async () => { | 72 | await step("Search for Gmail and see its registry card and domain", async () => { |
| 28 | const dialog = page.getByRole("dialog", { | 73 | await visit(page, "/integrations/browse"); |
| 29 | name: "Connect an integration", | 74 | const search = page.getByPlaceholder(/Search integrations, or paste a URL/); |
| 30 | }); | ||
| 31 | const search = dialog.getByPlaceholder(/Search or paste a URL/); | ||
| 32 | await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor(); | ||
| 33 | |||
| 34 | await search.fill("gmail"); | 75 | await search.fill("gmail"); |
| 35 | await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor(); | 76 | const card = page.getByTestId("catalog-gmail.com-openapi"); |
| 36 | 77 | await card.waitFor(); | |
| 37 | await search.fill("onedrive"); | 78 | await card.getByText("gmail.com").waitFor(); |
| 38 | await dialog.getByRole("link", { name: /^OneDrive Files\b/ }).waitFor(); | ||
| 39 | }); | 79 | }); |
| 40 | 80 | ||
| 41 | await step("OpenAPI add remains generic", async () => { | 81 | await step("Add Gmail in place: the card flips to View", async () => { |
| 42 | await page.goto("/integrations/add/openapi", { | 82 | await page |
| 43 | waitUntil: "domcontentloaded", | 83 | .getByTestId("catalog-gmail.com-openapi") |
| 44 | }); | 84 | .getByRole("button", { name: "Add Gmail API" }) |
| 45 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | 85 | .click(); |
| 46 | await page.getByText("OpenAPI Spec").waitFor(); | 86 | // The quick add fetches the hosted spec server-side and registers — |
| 47 | expect(await page.getByText("Customize your Google connection").count()).toBe(0); | 87 | // no navigation. Generous timeout: a real spec fetch + parse rides |
| 48 | expect(await page.getByText("Customize Microsoft Graph").count()).toBe(0); | 88 | // behind the click. |
| 89 | await page | ||
| 90 | .getByTestId("catalog-gmail.com-openapi") | ||
| 91 | .getByRole("link", { name: "View Gmail API" }) | ||
| 92 | .waitFor({ timeout: 120_000 }); | ||
| 93 | expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/); | ||
| 94 | // The registry surface slug became the namespace. | ||
| 95 | const href = await page | ||
| 96 | .getByTestId("catalog-gmail.com-openapi") | ||
| 97 | .getByRole("link", { name: "View Gmail API" }) | ||
| 98 | .getAttribute("href"); | ||
| 99 | expect(href).toContain("/integrations/google_gmail"); | ||
| 49 | }); | 100 | }); |
| 50 | 101 | ||
| 51 | await step("A Google service preset opens the OpenAPI add flow", async () => { | 102 | await step("Search for Outlook and see its separate registry card and domain", async () => { |
| 52 | await page.goto( | 103 | await visit(page, "/integrations/browse"); |
| 53 | "/integrations/add/openapi?preset=google-gmail&url=https%3A%2F%2Fwww.googleapis.com%2Fdiscovery%2Fv1%2Fapis%2Fgmail%2Fv1%2Frest", | 104 | await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("outlook"); |
| 54 | { waitUntil: "domcontentloaded" }, | 105 | const card = page.getByTestId("catalog-graph.microsoft.com-openapi"); |
| 55 | ); | 106 | await card.waitFor(); |
| 56 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | 107 | await card.getByText("graph.microsoft.com").waitFor(); |
| 57 | await expect.poll(() => page.locator("textarea").inputValue()).toContain("gmail"); | ||
| 58 | }); | ||
| 59 | |||
| 60 | await step("A Microsoft service preset opens the OpenAPI add flow", async () => { | ||
| 61 | await page.goto( | ||
| 62 | "/integrations/add/openapi?preset=microsoft-files&url=https%3A%2F%2Fgithub.com%2FUsefulSoftwareCo%2Fexecutor%2Freleases%2Fdownload%2Fgraph-slices%2Ffiles.yaml", | ||
| 63 | { waitUntil: "domcontentloaded" }, | ||
| 64 | ); | ||
| 65 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | ||
| 66 | await expect | ||
| 67 | .poll(() => page.locator("textarea").inputValue()) | ||
| 68 | .toContain("graph-slices/files.yaml"); | ||
| 69 | }); | 108 | }); |
| 70 | }); | 109 | }); |
| 71 | }), | 110 | }), |
| @@ -26,14 +26,24 @@ import { Api, Target } from "../src/services"; · 25 unmodified lines | |||
| 26 | 26 | ||
| 27 | const coreApi = composePluginApi([] as const); | 27 | const coreApi = composePluginApi([] as const); |
| 28 | 28 | ||
| 29 | /** The deterministic value the gated script returns once approved. Asserting | ||
| 30 | * the completed response against it byte-for-byte (non-ASCII included) pins | ||
| 31 | * output fidelity on the autoApprove path — the panel must render exactly | ||
| 32 | * what the script returned. */ | ||
| 33 | const approvedPayload = { | ||
| 34 | note: "auto-approved — résultat 完了 ✅", | ||
| 35 | values: [1, 2, 3], | ||
| 36 | }; | ||
| 37 | |||
| 29 | /** Sandbox code that creates a policy through the approval-gated core tool. The | 38 | /** Sandbox code that creates a policy through the approval-gated core tool. The |
| 30 | * pattern is unique-per-run and matches no real tool, so the rule is inert. */ | 39 | * pattern is unique-per-run and matches no real tool, so the rule is inert. */ |
| 31 | const createPolicyCode = (pattern: string) => ` | 40 | const createPolicyCode = (pattern: string) => ` |
| 32 | return await tools.executor.coreTools.policies.create({ | 41 | await tools.executor.coreTools.policies.create({ |
| 33 | owner: "user", | 42 | owner: "user", |
| 34 | pattern: ${JSON.stringify(pattern)}, | 43 | pattern: ${JSON.stringify(pattern)}, |
| 35 | action: "block", | 44 | action: "block", |
| 36 | }); | 45 | }); |
| 46 | return ${JSON.stringify(approvedPayload)}; | ||
| 37 | `; | 47 | `; |
| 38 | 48 | ||
| 39 | // Why this was long skipped: `autoApprove: true` came back `"paused"` instead of | 49 | // Why this was long skipped: `autoApprove: true` came back `"paused"` instead of |
| @@ -97,6 +107,12 @@ scenario( · 57 unmodified lines | |||
| 97 | expect(approved.status, "autoApprove runs the gated tool to completion").toBe("completed"); | 107 | expect(approved.status, "autoApprove runs the gated tool to completion").toBe("completed"); |
| 98 | if (approved.status !== "completed") return; // narrowing only | 108 | if (approved.status !== "completed") return; // narrowing only |
| 99 | expect(approved.isError, "the auto-approved run is not an error").toBe(false); | 109 | expect(approved.isError, "the auto-approved run is not an error").toBe(false); |
| 110 | expect(approved.text, "the returned value reaches the panel byte-identical").toBe( | ||
| 111 | JSON.stringify(approvedPayload, null, 2), | ||
| 112 | ); | ||
| 113 | expect(approved.structured, "the structured result mirrors the exact returned value").toEqual( | ||
| 114 | { status: "completed", result: approvedPayload, logs: [] }, | ||
| 115 | ); | ||
| 100 | 116 | ||
| 101 | const afterApproval = yield* client.policies.list(); | 117 | const afterApproval = yield* client.policies.list(); |
| 102 | expect( | 118 | expect( |
| @@ -169,7 +169,7 @@ scenario( · 168 unmodified lines | |||
| 169 | }); | 169 | }); |
| 170 | 170 | ||
| 171 | await step("The probe detected OAuth", async () => { | 171 | await step("The probe detected OAuth", async () => { |
| 172 | await page.getByText("Method 1 · Detected").waitFor(); | 172 | await page.getByText("OAuth · Detected").waitFor(); |
| 173 | // The OAuth editor declares discovery-at-connect, not pasted URLs. | 173 | // The OAuth editor declares discovery-at-connect, not pasted URLs. |
| 174 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); | 174 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); |
| 175 | }); | 175 | }); |
| @@ -1,6 +1,6 @@ | |||
| 1 | // Selfhost-only (browser): a spec/probe-DETECTED auth method is immutable in | 1 | // Selfhost-only (browser): a spec/probe-DETECTED auth method is immutable in |
| 2 | // the add flow. The shared AuthMethodListEditor renders detected methods as a | 2 | // the add flow. The shared AuthMethodListEditor renders detected methods as a |
| 3 | // disabled, read-only summary ("Pulled from spec. Remove to override.") with no | 3 | // disabled, read-only summary (declared-by-this-API footer) with no |
| 4 | // kind selector, so a user can't silently retype the spec's method into a kind | 4 | // kind selector, so a user can't silently retype the spec's method into a kind |
| 5 | // nothing backs. A method the user adds by hand stays fully editable. Both the | 5 | // nothing backs. A method the user adds by hand stays fully editable. Both the |
| 6 | // MCP and OpenAPI add flows compose the same editor, so one behavior, two | 6 | // MCP and OpenAPI add flows compose the same editor, so one behavior, two |
| @@ -18,7 +18,9 @@ import { scenario } from "../src/scenario"; · 11 unmodified lines | |||
| 18 | import { Browser, Target } from "../src/services"; | 18 | import { Browser, Target } from "../src/services"; |
| 19 | import { visit } from "../src/surfaces/browser"; | 19 | import { visit } from "../src/surfaces/browser"; |
| 20 | 20 | ||
| 21 | const REMOVE_HINT = "Pulled from spec. Remove to override."; | 21 | // The detected-method footer names the source and what happens next; the |
| 22 | // stable common prefix is the assertion target. | ||
| 23 | const REMOVE_HINT = "Declared by this API."; | ||
| 22 | 24 | ||
| 23 | scenario( | 25 | scenario( |
| 24 | "Detected auth · an MCP probe's OAuth method is immutable in the add flow", | 26 | "Detected auth · an MCP probe's OAuth method is immutable in the add flow", |
| @@ -39,20 +41,16 @@ scenario( · 14 unmodified lines | |||
| 39 | await step("Open the add-MCP flow pointed at the OAuth server", async () => { | 41 | await step("Open the add-MCP flow pointed at the OAuth server", async () => { |
| 40 | await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); | 42 | await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); |
| 41 | await page.getByText("How does this server authenticate?").waitFor(); | 43 | await page.getByText("How does this server authenticate?").waitFor(); |
| 42 | await page.getByText("Method 1 · Detected").waitFor(); | 44 | await page.getByText("OAuth · Detected").waitFor(); |
| 43 | }); | 45 | }); |
| 44 | 46 | ||
| 45 | await step("The detected method is locked: read-only, named, no selector", async () => { | 47 | await step("The detected method is locked: read-only, named, no selector", async () => { |
| 46 | // The kind is named explicitly ("OAuth"), the discovered-OAuth summary | 48 | // The kind is named in the row TITLE ("OAuth · Detected", asserted |
| 47 | // and override hint sit inside a disabled block, and there is NO | 49 | // above), the discovered-OAuth summary and override hint render |
| 48 | // editable kind selector (the FilterTabs render as buttons). | 50 | // read-only, and there is NO editable kind selector (the FilterTabs |
| 49 | await page.getByText("OAuth", { exact: true }).first().waitFor(); | 51 | // render as buttons). |
| 50 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); | 52 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); |
| 51 | await page.getByText(REMOVE_HINT).waitFor(); | 53 | await page.getByText(REMOVE_HINT).waitFor(); |
| 52 | expect( | ||
| 53 | await page.locator("[aria-disabled]").count(), | ||
| 54 | "the detected method renders a disabled (non-interactive) block", | ||
| 55 | ).toBeGreaterThan(0); | ||
| 56 | expect( | 54 | expect( |
| 57 | await page.getByRole("button", { name: "API key", exact: true }).count(), | 55 | await page.getByRole("button", { name: "API key", exact: true }).count(), |
| 58 | "no editable kind selector is shown for the detected method", | 56 | "no editable kind selector is shown for the detected method", |
| @@ -150,7 +148,9 @@ scenario( · 91 unmodified lines | |||
| 150 | .first() | 148 | .first() |
| 151 | .fill(spec.url); | 149 | .fill(spec.url); |
| 152 | await page.getByText("How does this API authenticate?").waitFor(); | 150 | await page.getByText("How does this API authenticate?").waitFor(); |
| 153 | await page.getByText("Method 2").waitFor(); | 151 | // Detected rows are titled by their kind now, not "Method N"; the |
| 152 | // hint below is what marks a rendered detected method. | ||
| 153 | await page.getByText(REMOVE_HINT).first().waitFor(); | ||
| 154 | }); | 154 | }); |
| 155 | 155 | ||
| 156 | await step("Both detected methods are locked, named, read-only", async () => { | 156 | await step("Both detected methods are locked, named, read-only", async () => { |
| @@ -162,8 +162,9 @@ scenario( · 5 unmodified lines | |||
| 162 | "both detected methods show the remove-to-override hint", | 162 | "both detected methods show the remove-to-override hint", |
| 163 | ).toBe(2); | 163 | ).toBe(2); |
| 164 | await page.getByText("https://api.acme.test/oauth/authorize").waitFor(); | 164 | await page.getByText("https://api.acme.test/oauth/authorize").waitFor(); |
| 165 | await page.getByText("API key", { exact: true }).first().waitFor(); | 165 | // Kinds are named in the row TITLES ("API key · …" / "OAuth · …"). |
| 166 | await page.getByText("OAuth", { exact: true }).first().waitFor(); | 166 | await page.getByText("API key · ").first().waitFor(); |
| 167 | await page.getByText("OAuth · ").first().waitFor(); | ||
| 167 | expect( | 168 | expect( |
| 168 | await page.getByRole("button", { name: "OAuth", exact: true }).count(), | 169 | await page.getByRole("button", { name: "OAuth", exact: true }).count(), |
| 169 | "no editable kind selector is shown for the detected methods", | 170 | "no editable kind selector is shown for the detected methods", |
| @@ -61,7 +61,7 @@ scenario( · 60 unmodified lines | |||
| 61 | }); | 61 | }); |
| 62 | 62 | ||
| 63 | await step("The probe seeded a detected Bearer-header method", async () => { | 63 | await step("The probe seeded a detected Bearer-header method", async () => { |
| 64 | await page.getByText("Method 1 · Detected").waitFor(); | 64 | await page.getByText("API key · Detected").waitFor(); |
| 65 | // The preview card flags the gate rather than failing the probe. | 65 | // The preview card flags the gate rather than failing the probe. |
| 66 | await page.getByText("Auth required").first().waitFor(); | 66 | await page.getByText("Auth required").first().waitFor(); |
| 67 | }); | 67 | }); |
| @@ -51,7 +51,7 @@ scenario( · 50 unmodified lines | |||
| 51 | addUrl.searchParams.set("url", server.endpoint); | 51 | addUrl.searchParams.set("url", server.endpoint); |
| 52 | await visit(page, addUrl.toString()); | 52 | await visit(page, addUrl.toString()); |
| 53 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); | 53 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); |
| 54 | await page.getByText("Method 1 · Detected").waitFor(); | 54 | await page.getByText("OAuth · Detected").waitFor(); |
| 55 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); | 55 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); |
| 56 | }); | 56 | }); |
| 57 | 57 | ||
| @@ -1,5 +1,33 @@ | |||
| 1 | # @executor-js/example-all-plugins | 1 | # @executor-js/example-all-plugins |
| 2 | 2 | ||
| 3 | ## 0.0.67 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed)]: | ||
| 8 | - @executor-js/plugin-mcp@1.6.6 | ||
| 9 | - @executor-js/plugin-graphql@1.6.6 | ||
| 10 | - @executor-js/plugin-onepassword@1.6.6 | ||
| 11 | - @executor-js/plugin-openapi@1.6.6 | ||
| 12 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 13 | - @executor-js/sdk@1.6.6 | ||
| 14 | - @executor-js/plugin-file-secrets@1.6.6 | ||
| 15 | - @executor-js/plugin-keychain@1.6.6 | ||
| 16 | |||
| 17 | ## 0.0.66 | ||
| 18 | |||
| 19 | ### Patch Changes | ||
| 20 | |||
| 21 | - Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]: | ||
| 22 | - @executor-js/plugin-mcp@1.6.5 | ||
| 23 | - @executor-js/sdk@1.6.5 | ||
| 24 | - @executor-js/plugin-file-secrets@1.6.5 | ||
| 25 | - @executor-js/plugin-graphql@1.6.5 | ||
| 26 | - @executor-js/plugin-keychain@1.6.5 | ||
| 27 | - @executor-js/plugin-onepassword@1.6.5 | ||
| 28 | - @executor-js/plugin-openapi@1.6.5 | ||
| 29 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 30 | |||
| 3 | ## 0.0.65 | 31 | ## 0.0.65 |
| 4 | 32 | ||
| 5 | ### Patch Changes | 33 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/example-all-plugins", | 2 | "name": "@executor-js/example-all-plugins", |
| 3 | "version": "0.0.65", | 3 | "version": "0.0.67", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "scripts": { | 6 | "scripts": { |
| @@ -1,5 +1,21 @@ | |||
| 1 | # @executor-js/example-docs-sdk-quickstart | 1 | # @executor-js/example-docs-sdk-quickstart |
| 2 | 2 | ||
| 3 | ## 0.0.52 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/plugin-openapi@1.6.6 | ||
| 9 | - @executor-js/sdk@1.6.6 | ||
| 10 | |||
| 11 | ## 0.0.51 | ||
| 12 | |||
| 13 | ### Patch Changes | ||
| 14 | |||
| 15 | - Updated dependencies []: | ||
| 16 | - @executor-js/sdk@1.6.5 | ||
| 17 | - @executor-js/plugin-openapi@1.6.5 | ||
| 18 | |||
| 3 | ## 0.0.50 | 19 | ## 0.0.50 |
| 4 | 20 | ||
| 5 | ### Patch Changes | 21 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/example-docs-sdk-quickstart", | 2 | "name": "@executor-js/example-docs-sdk-quickstart", |
| 3 | "version": "0.0.50", | 3 | "version": "0.0.52", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "scripts": { | 6 | "scripts": { |
| @@ -24,6 +24,7 @@ · 23 unmodified lines | |||
| 24 | "packages/*/*", | 24 | "packages/*/*", |
| 25 | "packages/react", | 25 | "packages/react", |
| 26 | "packages/app", | 26 | "packages/app", |
| 27 | "packages/onboarding-demo", | ||
| 27 | "apps/*", | 28 | "apps/*", |
| 28 | "examples/*", | 29 | "examples/*", |
| 29 | "e2e" | 30 | "e2e" |
| @@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './ · 16 unmodified lines | |||
| 17 | import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts' | 17 | import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts' |
| 18 | import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' | 18 | import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug' |
| 19 | import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' | 19 | import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId' |
| 20 | import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../react/src/routes/integrations.browse' | ||
| 20 | import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' | 21 | import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace' |
| 21 | import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug' | 22 | import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug' |
| 22 | import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId' | 23 | import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId' |
| @@ -70,6 +71,12 @@ const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute = · 47 unmodified lines | |||
| 70 | path: '/{-$orgSlug}/resume/$executionId', | 71 | path: '/{-$orgSlug}/resume/$executionId', |
| 71 | getParentRoute: () => rootRouteImport, | 72 | getParentRoute: () => rootRouteImport, |
| 72 | } as any) | 73 | } as any) |
| 74 | const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute = | ||
| 75 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport.update({ | ||
| 76 | id: '/{-$orgSlug}/integrations/browse', | ||
| 77 | path: '/{-$orgSlug}/integrations/browse', | ||
| 78 | getParentRoute: () => rootRouteImport, | ||
| 79 | } as any) | ||
| 73 | const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute = | 80 | const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute = |
| 74 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport.update({ | 81 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport.update({ |
| 75 | id: '/{-$orgSlug}/integrations/$namespace', | 82 | id: '/{-$orgSlug}/integrations/$namespace', |
| @@ -113,6 +120,7 @@ export interface FileRoutesByFullPath { · 37 unmodified lines | |||
| 113 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute | 120 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute |
| 114 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute | 121 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute |
| 115 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute | 122 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 123 | '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 116 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute | 124 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 117 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute | 125 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 118 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 126 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -128,6 +136,7 @@ export interface FileRoutesByTo { · 9 unmodified lines | |||
| 128 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute | 136 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute |
| 129 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute | 137 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute |
| 130 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute | 138 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 139 | '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 131 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute | 140 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 132 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute | 141 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 133 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 142 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -144,6 +153,7 @@ export interface FileRoutesById { · 10 unmodified lines | |||
| 144 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute | 153 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute |
| 145 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute | 154 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute |
| 146 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute | 155 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 156 | '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 147 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute | 157 | '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 148 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute | 158 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute |
| 149 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 159 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| @@ -161,6 +171,7 @@ export interface FileRouteTypes { · 11 unmodified lines | |||
| 161 | | '/{-$orgSlug}/artifacts/$artifactId' | 171 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 162 | | '/{-$orgSlug}/connect/$integrationSlug' | 172 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 163 | | '/{-$orgSlug}/integrations/$namespace' | 173 | | '/{-$orgSlug}/integrations/$namespace' |
| 174 | | '/{-$orgSlug}/integrations/browse' | ||
| 164 | | '/{-$orgSlug}/resume/$executionId' | 175 | | '/{-$orgSlug}/resume/$executionId' |
| 165 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 176 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 166 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 177 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -176,6 +187,7 @@ export interface FileRouteTypes { · 9 unmodified lines | |||
| 176 | | '/{-$orgSlug}/artifacts/$artifactId' | 187 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 177 | | '/{-$orgSlug}/connect/$integrationSlug' | 188 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 178 | | '/{-$orgSlug}/integrations/$namespace' | 189 | | '/{-$orgSlug}/integrations/$namespace' |
| 190 | | '/{-$orgSlug}/integrations/browse' | ||
| 179 | | '/{-$orgSlug}/resume/$executionId' | 191 | | '/{-$orgSlug}/resume/$executionId' |
| 180 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 192 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 181 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 193 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -191,6 +203,7 @@ export interface FileRouteTypes { · 9 unmodified lines | |||
| 191 | | '/{-$orgSlug}/artifacts/$artifactId' | 203 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 192 | | '/{-$orgSlug}/connect/$integrationSlug' | 204 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 193 | | '/{-$orgSlug}/integrations/$namespace' | 205 | | '/{-$orgSlug}/integrations/$namespace' |
| 206 | | '/{-$orgSlug}/integrations/browse' | ||
| 194 | | '/{-$orgSlug}/resume/$executionId' | 207 | | '/{-$orgSlug}/resume/$executionId' |
| 195 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 208 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 196 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 209 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -206,6 +219,7 @@ export interface RootRouteChildren { · 9 unmodified lines | |||
| 206 | DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute | 219 | DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute |
| 207 | DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute | 220 | DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute |
| 208 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute | 221 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute |
| 222 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute | ||
| 209 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute | 223 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute |
| 210 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute | 224 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute |
| 211 | DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute | 225 | DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute |
| @@ -269,6 +283,13 @@ declare module '@tanstack/react-router' { · 57 unmodified lines | |||
| 269 | preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport | 283 | preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport |
| 270 | parentRoute: typeof rootRouteImport | 284 | parentRoute: typeof rootRouteImport |
| 271 | } | 285 | } |
| 286 | '/{-$orgSlug}/integrations/browse': { | ||
| 287 | id: '/{-$orgSlug}/integrations/browse' | ||
| 288 | path: '/{-$orgSlug}/integrations/browse' | ||
| 289 | fullPath: '/{-$orgSlug}/integrations/browse' | ||
| 290 | preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport | ||
| 291 | parentRoute: typeof rootRouteImport | ||
| 292 | } | ||
| 272 | '/{-$orgSlug}/integrations/$namespace': { | 293 | '/{-$orgSlug}/integrations/$namespace': { |
| 273 | id: '/{-$orgSlug}/integrations/$namespace' | 294 | id: '/{-$orgSlug}/integrations/$namespace' |
| 274 | path: '/{-$orgSlug}/integrations/$namespace' | 295 | path: '/{-$orgSlug}/integrations/$namespace' |
| @@ -353,6 +374,8 @@ const rootRouteChildren: RootRouteChildren = { · 78 unmodified lines | |||
| 353 | DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute, | 374 | DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute, |
| 354 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: | 375 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: |
| 355 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute, | 376 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute, |
| 377 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute: | ||
| 378 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute, | ||
| 356 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: | 379 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: |
| 357 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute, | 380 | DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute, |
| 358 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: | 381 | DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: |
| @@ -136,6 +136,12 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { · 135 unmodified lines | |||
| 136 | : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", | 136 | : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", |
| 137 | ].join(" ")} | 137 | ].join(" ")} |
| 138 | > | 138 | > |
| 139 | {/* Same inputs as the integrations list. Passing only the | ||
| 140 | slug and kind meant an icon could be found ONLY when a | ||
| 141 | bundled preset happened to match — anything added from the | ||
| 142 | registry has a slug no preset knows, so the sidebar sat | ||
| 143 | blank while the same integration showed its mark in the | ||
| 144 | list. The display URL is what a favicon is derived from. */} | ||
| 139 | <IntegrationIconWithAccount | 145 | <IntegrationIconWithAccount |
| 140 | icon={integrationPresetIconUrl( | 146 | icon={integrationPresetIconUrl( |
| 141 | { id: slug, kind: integration.kind, name, url: integration.displayUrl }, | 147 | { id: slug, kind: integration.kind, name, url: integration.displayUrl }, |
| @@ -1,5 +1,19 @@ | |||
| 1 | # @executor-js/analytics | 1 | # @executor-js/analytics |
| 2 | 2 | ||
| 3 | ## 0.1.13 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d)]: | ||
| 8 | - @executor-js/execution@1.6.6 | ||
| 9 | |||
| 10 | ## 0.1.12 | ||
| 11 | |||
| 12 | ### Patch Changes | ||
| 13 | |||
| 14 | - Updated dependencies []: | ||
| 15 | - @executor-js/execution@1.6.5 | ||
| 16 | |||
| 3 | ## 0.1.11 | 17 | ## 0.1.11 |
| 4 | 18 | ||
| 5 | ### Patch Changes | 19 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/analytics", | 2 | "name": "@executor-js/analytics", |
| 3 | "version": "0.1.11", | 3 | "version": "0.1.13", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/analytics", | 5 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/analytics", |
| 6 | "bugs": { | 6 | "bugs": { |
| @@ -1,5 +1,23 @@ | |||
| 1 | # @executor-js/api | 1 | # @executor-js/api |
| 2 | 2 | ||
| 3 | ## 1.4.69 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d)]: | ||
| 8 | - @executor-js/execution@1.6.6 | ||
| 9 | - @executor-js/host-mcp@1.4.4 | ||
| 10 | - @executor-js/sdk@1.6.6 | ||
| 11 | |||
| 12 | ## 1.4.68 | ||
| 13 | |||
| 14 | ### Patch Changes | ||
| 15 | |||
| 16 | - Updated dependencies []: | ||
| 17 | - @executor-js/sdk@1.6.5 | ||
| 18 | - @executor-js/execution@1.6.5 | ||
| 19 | - @executor-js/host-mcp@1.4.4 | ||
| 20 | |||
| 3 | ## 1.4.67 | 21 | ## 1.4.67 |
| 4 | 22 | ||
| 5 | ### Patch Changes | 23 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/api", | 2 | "name": "@executor-js/api", |
| 3 | "version": "1.4.67", | 3 | "version": "1.4.69", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "exports": { | 6 | "exports": { |
| @@ -1,5 +1,19 @@ | |||
| 1 | # @executor-js/cli | 1 | # @executor-js/cli |
| 2 | 2 | ||
| 3 | ## 0.2.56 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/sdk@1.6.6 | ||
| 9 | |||
| 10 | ## 0.2.55 | ||
| 11 | |||
| 12 | ### Patch Changes | ||
| 13 | |||
| 14 | - Updated dependencies []: | ||
| 15 | - @executor-js/sdk@1.6.5 | ||
| 16 | |||
| 3 | ## 0.2.54 | 17 | ## 0.2.54 |
| 4 | 18 | ||
| 5 | ### Patch Changes | 19 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/cli", | 2 | "name": "@executor-js/cli", |
| 3 | "version": "0.2.54", | 3 | "version": "0.2.56", |
| 4 | "description": "CLI for the executor SDK — schema generation, migrations", | 4 | "description": "CLI for the executor SDK — schema generation, migrations", |
| 5 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/cli", | 5 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/cli", |
| 6 | "bugs": { | 6 | "bugs": { |
| @@ -1,5 +1,19 @@ | |||
| 1 | # @executor-js/config | 1 | # @executor-js/config |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/sdk@1.6.6 | ||
| 9 | |||
| 10 | ## 1.6.5 | ||
| 11 | |||
| 12 | ### Patch Changes | ||
| 13 | |||
| 14 | - Updated dependencies []: | ||
| 15 | - @executor-js/sdk@1.6.5 | ||
| 16 | |||
| 3 | ## 1.6.4 | 17 | ## 1.6.4 |
| 4 | 18 | ||
| 5 | ### Patch Changes | 19 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/config", | 2 | "name": "@executor-js/config", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/config", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/config", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -1,5 +1,25 @@ | |||
| 1 | # @executor-js/execution | 1 | # @executor-js/execution |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - [#1866](https://github.com/UsefulSoftwareCo/executor/pull/1866) [`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Large execute results are measured once, not once per span** | ||
| 8 | |||
| 9 | The result-size telemetry probe serializes the whole returned value to count its characters, and that cost grows with the payload. The same result object was walked again every time it was stamped onto another span: an operator-approved run measured it twice (inner and outer span), and every retried `resume` that replayed a settled outcome measured it again. The measurement is now computed once per result object and reused, so a large result pays one size walk no matter how many spans report it. Response text, structured content, and span attribute values are unchanged. | ||
| 10 | |||
| 11 | - Updated dependencies []: | ||
| 12 | - @executor-js/sdk@1.6.6 | ||
| 13 | - @executor-js/codemode-core@1.6.6 | ||
| 14 | |||
| 15 | ## 1.6.5 | ||
| 16 | |||
| 17 | ### Patch Changes | ||
| 18 | |||
| 19 | - Updated dependencies []: | ||
| 20 | - @executor-js/sdk@1.6.5 | ||
| 21 | - @executor-js/codemode-core@1.6.5 | ||
| 22 | |||
| 3 | ## 1.6.4 | 23 | ## 1.6.4 |
| 4 | 24 | ||
| 5 | ### Patch Changes | 25 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/execution", | 2 | "name": "@executor-js/execution", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/execution", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/execution", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -5,7 +5,7 @@ import { createExecutor, definePlugin } from "@executor-js/sdk"; · 4 unmodified lines | |||
| 5 | import { makeTestConfig } from "@executor-js/sdk/testing"; | 5 | import { makeTestConfig } from "@executor-js/sdk/testing"; |
| 6 | import type { CodeExecutor, ExecuteResult } from "@executor-js/codemode-core"; | 6 | import type { CodeExecutor, ExecuteResult } from "@executor-js/codemode-core"; |
| 7 | 7 | ||
| 8 | import { createExecutionEngine, formatPausedExecution } from "./engine"; | 8 | import { createExecutionEngine, formatExecuteResult, formatPausedExecution } from "./engine"; |
| 9 | import { FormElicitation } from "@executor-js/sdk/core"; | 9 | import { FormElicitation } from "@executor-js/sdk/core"; |
| 10 | 10 | ||
| 11 | // Regression for the hang reported as the executor-MCP "180s timeout" against | 11 | // Regression for the hang reported as the executor-MCP "180s timeout" against |
| @@ -140,3 +140,139 @@ describe("formatPausedExecution approval terms", () => { · 128 unmodified lines | |||
| 140 | expect((result.structured["interaction"] as Record<string, unknown>)["meta"]).toBeUndefined(); | 140 | expect((result.structured["interaction"] as Record<string, unknown>)["meta"]).toBeUndefined(); |
| 141 | }); | 141 | }); |
| 142 | }); | 142 | }); |
| 143 | |||
| 144 | // Pins the exact preview and structured shapes so the serialization dedupe in | ||
| 145 | // the engine cannot drift them: the preview stays pretty-printed (indent 2), | ||
| 146 | // truncation keeps its exact suffix, and structured carries the raw value. | ||
| 147 | describe("formatExecuteResult output identity", () => { | ||
| 148 | const MAX_PREVIEW_CHARS = 30_000; | ||
| 149 | |||
| 150 | it("renders an object result as pretty-printed JSON and keeps the raw value in structured", () => { | ||
| 151 | const value = { café: "naïve — ✓", emoji: "🎉", nested: { π: 3.14159 } }; | ||
| 152 | const formatted = formatExecuteResult({ result: value, logs: [] }); | ||
| 153 | |||
| 154 | expect(formatted.text).toBe(JSON.stringify(value, null, 2)); | ||
| 155 | expect(formatted.structured).toEqual({ status: "completed", result: value, logs: [] }); | ||
| 156 | expect(formatted.structured["result"]).toBe(value); | ||
| 157 | expect(formatted.isError).toBe(false); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it("truncates a long preview with the exact suffix and untouched structured value", () => { | ||
| 161 | const value = { data: "é🎉".repeat(12_000) }; | ||
| 162 | const pretty = JSON.stringify(value, null, 2); | ||
| 163 | expect(pretty.length).toBeGreaterThan(MAX_PREVIEW_CHARS); | ||
| 164 | |||
| 165 | const formatted = formatExecuteResult({ result: value }); | ||
| 166 | |||
| 167 | expect(formatted.text).toBe( | ||
| 168 | `${pretty.slice(0, MAX_PREVIEW_CHARS)}\n... [truncated ${pretty.length - MAX_PREVIEW_CHARS} chars]`, | ||
| 169 | ); | ||
| 170 | expect(formatted.structured["result"]).toBe(value); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it("returns a string result verbatim", () => { | ||
| 174 | const formatted = formatExecuteResult({ result: "plain — ✓", logs: ["l1", "l2"] }); | ||
| 175 | |||
| 176 | expect(formatted.text).toBe("plain — ✓\n\nLogs:\nl1\nl2"); | ||
| 177 | expect(formatted.structured).toEqual({ | ||
| 178 | status: "completed", | ||
| 179 | result: "plain — ✓", | ||
| 180 | logs: ["l1", "l2"], | ||
| 181 | }); | ||
| 182 | }); | ||
| 183 | |||
| 184 | it("renders an error result with logs and truncation", () => { | ||
| 185 | const formatted = formatExecuteResult({ | ||
| 186 | result: null, | ||
| 187 | error: "boom", | ||
| 188 | errorKind: "tool_error", | ||
| 189 | logs: ["x".repeat(MAX_PREVIEW_CHARS)], | ||
| 190 | }); | ||
| 191 | |||
| 192 | const untruncated = `Error: boom\n\nLogs:\n${"x".repeat(MAX_PREVIEW_CHARS)}`; | ||
| 193 | expect(formatted.text).toBe( | ||
| 194 | `${untruncated.slice(0, MAX_PREVIEW_CHARS)}\n... [truncated ${untruncated.length - MAX_PREVIEW_CHARS} chars]`, | ||
| 195 | ); | ||
| 196 | expect(formatted.isError).toBe(true); | ||
| 197 | expect(formatted.structured).toEqual({ | ||
| 198 | status: "error", | ||
| 199 | error: "boom", | ||
| 200 | logs: ["x".repeat(MAX_PREVIEW_CHARS)], | ||
| 201 | }); | ||
| 202 | }); | ||
| 203 | }); | ||
| 204 | |||
| 205 | // The result-size span metric walks the whole result value with | ||
| 206 | // `JSON.stringify`. A `toJSON` probe inside the fixture counts full walks: | ||
| 207 | // every complete stringify of the value visits the probe exactly once. | ||
| 208 | describe("execute outcome measurement cost", () => { | ||
| 209 | const instrumented = () => { | ||
| 210 | let walks = 0; | ||
| 211 | const probe = { | ||
| 212 | toJSON: () => { | ||
| 213 | walks += 1; | ||
| 214 | return "probe"; | ||
| 215 | }, | ||
| 216 | }; | ||
| 217 | return { value: { data: [1, 2, 3], probe }, walks: () => walks }; | ||
| 218 | }; | ||
| 219 | |||
| 220 | const executorFor = (result: ExecuteResult): CodeExecutor<FakeRuntimeError> => ({ | ||
| 221 | execute: () => Effect.succeed(result), | ||
| 222 | }); | ||
| 223 | |||
| 224 | it.effect("walks the result value once on the pausable path", () => | ||
| 225 | Effect.gen(function* () { | ||
| 226 | const executor = yield* makeExecutor(); | ||
| 227 | const fixture = instrumented(); | ||
| 228 | const engine = createExecutionEngine({ | ||
| 229 | executor, | ||
| 230 | codeExecutor: executorFor({ result: fixture.value, logs: [] }), | ||
| 231 | }); | ||
| 232 | |||
| 233 | const outcome = yield* engine.executeWithPause("noop"); | ||
| 234 | |||
| 235 | expect(outcome.status).toBe("completed"); | ||
| 236 | expect(fixture.walks()).toBe(1); | ||
| 237 | }), | ||
| 238 | ); | ||
| 239 | |||
| 240 | it.effect("walks the result value once even when autoApprove stamps two spans", () => | ||
| 241 | Effect.gen(function* () { | ||
| 242 | const executor = yield* makeExecutor(); | ||
| 243 | const fixture = instrumented(); | ||
| 244 | const engine = createExecutionEngine({ | ||
| 245 | executor, | ||
| 246 | codeExecutor: executorFor({ result: fixture.value, logs: [] }), | ||
| 247 | }); | ||
| 248 | |||
| 249 | // autoApprove runs the inline path (inner span annotation) and then | ||
| 250 | // annotates the outer pausable span with the same result. | ||
| 251 | const outcome = yield* engine.executeWithPause("noop", { autoApprove: true }); | ||
| 252 | |||
| 253 | expect(outcome.status).toBe("completed"); | ||
| 254 | expect(fixture.walks()).toBe(1); | ||
| 255 | }), | ||
| 256 | ); | ||
| 257 | |||
| 258 | it.effect("a completed execution plus its preview pays two walks total", () => | ||
| 259 | Effect.gen(function* () { | ||
| 260 | const executor = yield* makeExecutor(); | ||
| 261 | const fixture = instrumented(); | ||
| 262 | const engine = createExecutionEngine({ | ||
| 263 | executor, | ||
| 264 | codeExecutor: executorFor({ result: fixture.value, logs: [] }), | ||
| 265 | }); | ||
| 266 | |||
| 267 | const outcome = yield* engine.executeWithPause("noop"); | ||
| 268 | expect(outcome.status).toBe("completed"); | ||
| 269 | if (outcome.status !== "completed") return; | ||
| 270 | |||
| 271 | // One compact walk for the span size metric, one pretty walk for the | ||
| 272 | // preview. They produce different strings (compact vs indent 2), so | ||
| 273 | // neither can be derived from the other. | ||
| 274 | formatExecuteResult(outcome.result); | ||
| 275 | expect(fixture.walks()).toBe(2); | ||
| 276 | }), | ||
| 277 | ); | ||
| 278 | }); | ||
| @@ -80,6 +80,32 @@ const measureResultChars = (value: unknown): number => { · 79 unmodified lines | |||
| 80 | } | 80 | } |
| 81 | }; | 81 | }; |
| 82 | 82 | ||
| 83 | /** | ||
| 84 | * Outcome attributes are a pure function of an immutable `ExecuteResult`, but | ||
| 85 | * the same result object is annotated more than once: the `autoApprove` path | ||
| 86 | * stamps both the inner inline span and the outer pausable span, and resume | ||
| 87 | * retries replay the settled result cached per execution id. The size probe | ||
| 88 | * walks the whole result value (`JSON.stringify`), so its cost grows with the | ||
| 89 | * payload — memoize the record per result object so each result is walked | ||
| 90 | * once, no matter how many spans it is stamped onto. | ||
| 91 | */ | ||
| 92 | const executeOutcomeAttributesCache = new WeakMap<ExecuteResult, Record<string, unknown>>(); | ||
| 93 | |||
| 94 | const executeOutcomeAttributes = (result: ExecuteResult): Record<string, unknown> => { | ||
| 95 | const cached = executeOutcomeAttributesCache.get(result); | ||
| 96 | if (cached) return cached; | ||
| 97 | const attributes = { | ||
| 98 | "mcp.execute.result_chars": measureResultChars(result.result), | ||
| 99 | "mcp.execute.log_chars": result.logs?.reduce((total, line) => total + line.length, 0) ?? 0, | ||
| 100 | "mcp.execute.emitted": result.output?.length ?? 0, | ||
| 101 | ...(result.error | ||
| 102 | ? { "mcp.execute.outcome": "fail", "mcp.execute.error_kind": result.errorKind ?? "unknown" } | ||
| 103 | : { "mcp.execute.outcome": "ok" }), | ||
| 104 | }; | ||
| 105 | executeOutcomeAttributesCache.set(result, attributes); | ||
| 106 | return attributes; | ||
| 107 | }; | ||
| 108 | |||
| 83 | /** | 109 | /** |
| 84 | * Stamp the current `mcp.execute` / `mcp.execute.resume` span with how the | 110 | * Stamp the current `mcp.execute` / `mcp.execute.resume` span with how the |
| 85 | * execution ended and how much data it sent back toward model context. | 111 | * execution ended and how much data it sent back toward model context. |
| @@ -89,14 +115,7 @@ const measureResultChars = (value: unknown): number => { · 3 unmodified lines | |||
| 89 | * or result content itself. | 115 | * or result content itself. |
| 90 | */ | 116 | */ |
| 91 | const annotateExecuteOutcome = (result: ExecuteResult) => | 117 | const annotateExecuteOutcome = (result: ExecuteResult) => |
| 92 | Effect.annotateCurrentSpan({ | 118 | Effect.annotateCurrentSpan(executeOutcomeAttributes(result)); |
| 93 | "mcp.execute.result_chars": measureResultChars(result.result), | ||
| 94 | "mcp.execute.log_chars": result.logs?.reduce((total, line) => total + line.length, 0) ?? 0, | ||
| 95 | "mcp.execute.emitted": result.output?.length ?? 0, | ||
| 96 | ...(result.error | ||
| 97 | ? { "mcp.execute.outcome": "fail", "mcp.execute.error_kind": result.errorKind ?? "unknown" } | ||
| 98 | : { "mcp.execute.outcome": "ok" }), | ||
| 99 | }); | ||
| 100 | 119 | ||
| 101 | const annotateExecutionOutcome = (execution: ExecutionResult) => | 120 | const annotateExecutionOutcome = (execution: ExecutionResult) => |
| 102 | execution.status === "paused" | 121 | execution.status === "paused" |
| @@ -1,5 +1,9 @@ | |||
| 1 | # @executor-js/sdk | 1 | # @executor-js/sdk |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ## 1.6.5 | ||
| 6 | |||
| 3 | ## 1.6.4 | 7 | ## 1.6.4 |
| 4 | 8 | ||
| 5 | ### Patch Changes | 9 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/sdk", | 2 | "name": "@executor-js/sdk", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/sdk", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/sdk", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -124,6 +124,12 @@ export interface IntegrationPreset { · 123 unmodified lines | |||
| 124 | readonly specOverrides?: readonly unknown[]; | 124 | readonly specOverrides?: readonly unknown[]; |
| 125 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; | 125 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; |
| 126 | readonly healthCheck?: HealthCheckSpec; | 126 | readonly healthCheck?: HealthCheckSpec; |
| 127 | /** The public registry lists this product, so the picker shows the | ||
| 128 | * registry's card instead of a preset card (the preset's knowledge still | ||
| 129 | * rides quick add). Built-in presets set this; a deployment's custom | ||
| 130 | * preset does not, and keeps its own card — hiding it would make a | ||
| 131 | * private API undiscoverable. */ | ||
| 132 | readonly registryListed?: boolean; | ||
| 127 | } | 133 | } |
| 128 | 134 | ||
| 129 | export type IntegrationPresetAuthentication = | 135 | export type IntegrationPresetAuthentication = |
| @@ -199,6 +205,34 @@ export interface EditSheetSectionProps { · 69 unmodified lines | |||
| 199 | readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void; | 205 | readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void; |
| 200 | } | 206 | } |
| 201 | 207 | ||
| 208 | /** What a registry row already knows about a connect target — enough, for | ||
| 209 | * some plugins, to register without showing the configuration screen. */ | ||
| 210 | export interface IntegrationQuickAddInput { | ||
| 211 | /** The connect target: MCP endpoint, OpenAPI spec URL, or GraphQL endpoint. */ | ||
| 212 | readonly url: string; | ||
| 213 | /** Display name for the integration ("Stripe MCP", "Outlook Mail API"). */ | ||
| 214 | readonly name: string; | ||
| 215 | /** Registry surface slug, used as the namespace seed when present. */ | ||
| 216 | readonly slug?: string; | ||
| 217 | /** The registry PRODUCT's domain (notion.com) — the identity credential | ||
| 218 | * guidance and favicons key on. Often differs from the connect URL's | ||
| 219 | * host: specs live on code hosts. */ | ||
| 220 | readonly domain?: string; | ||
| 221 | /** Registry-declared credential placement, e.g. "Authorization: {api_key}". */ | ||
| 222 | readonly authHeader?: string; | ||
| 223 | /** Registry-declared credential kind ("none", "oauth", "api_key", …). */ | ||
| 224 | readonly authKind?: string; | ||
| 225 | /** RFC 6902 patch the registry says to apply to the fetched spec. */ | ||
| 226 | readonly specOverrides?: readonly unknown[]; | ||
| 227 | } | ||
| 228 | |||
| 229 | export type IntegrationQuickAddResult = | ||
| 230 | /** Registered; `slug` is the created integration's namespace. */ | ||
| 231 | | { readonly ok: true; readonly slug: string } | ||
| 232 | /** Could not add headlessly — the host falls back to the configuration | ||
| 233 | * screen, which renders the failure with full context. */ | ||
| 234 | | { readonly ok: false; readonly reason: string }; | ||
| 235 | |||
| 202 | export interface IntegrationPlugin { | 236 | export interface IntegrationPlugin { |
| 203 | /** Unique key matching the SDK plugin id (e.g. "openapi"). */ | 237 | /** Unique key matching the SDK plugin id (e.g. "openapi"). */ |
| 204 | readonly key: string; | 238 | readonly key: string; |
| @@ -213,6 +247,21 @@ export interface IntegrationPlugin { · 8 unmodified lines | |||
| 213 | readonly initialUrl?: string; | 247 | readonly initialUrl?: string; |
| 214 | readonly initialPreset?: string; | 248 | readonly initialPreset?: string; |
| 215 | readonly initialNamespace?: string; | 249 | readonly initialNamespace?: string; |
| 250 | /** Registry-declared credential placement for the surface, e.g. | ||
| 251 | * "Authorization: {api_key}" — the pattern Linear's no-Bearer personal | ||
| 252 | * keys need. Plugins whose surfaces can't self-describe auth (GraphQL) | ||
| 253 | * seed their auth-method editor from it. */ | ||
| 254 | readonly initialAuthHeader?: string; | ||
| 255 | readonly initialAuthNote?: string; | ||
| 256 | /** Registry-declared credential kind ("none", "oauth", "api_key", …). | ||
| 257 | * Lets a flow whose live probe fails, or whose surface can't be probed, | ||
| 258 | * still declare the right method — an authless MCP server is a fact the | ||
| 259 | * registry already knows. */ | ||
| 260 | readonly initialAuthKind?: string; | ||
| 261 | /** JSON-encoded RFC 6902 patch the registry says to apply to the fetched | ||
| 262 | * spec — the registry's mechanism for improving a vendor's published | ||
| 263 | * document over time without hosting a fork. */ | ||
| 264 | readonly initialSpecOverrides?: string; | ||
| 216 | }>; | 265 | }>; |
| 217 | /** Legacy full-page edit surface. No host renders this anymore — plugin | 266 | /** Legacy full-page edit surface. No host renders this anymore — plugin |
| 218 | * configuration lives in the integration Edit sheet via `editSheet`. */ | 267 | * configuration lives in the integration Edit sheet via `editSheet`. */ |
| @@ -245,6 +294,17 @@ export interface IntegrationPlugin { · 26 unmodified lines | |||
| 245 | * Call from the host on intent (hover/focus) so the chunks land before the | 294 | * Call from the host on intent (hover/focus) so the chunks land before the |
| 246 | * user navigates into the add page. Idempotent. */ | 295 | * user navigates into the add page. Idempotent. */ |
| 247 | readonly preload?: () => void; | 296 | readonly preload?: () => void; |
| 297 | /** Headless one-click add for a registry row whose facts are already known | ||
| 298 | * (URL + auth indicators). A HOOK, not a plain function, because each | ||
| 299 | * plugin binds its own mutation atoms — the host mounts one bridge | ||
| 300 | * component per plugin to collect the bound callbacks. Absent means the | ||
| 301 | * plugin always needs its configuration screen. A `{ok: false}` result is | ||
| 302 | * an expected outcome (unreachable server, slug collision), not an error: | ||
| 303 | * the host falls back to the configuration screen prefilled with the same | ||
| 304 | * facts. */ | ||
| 305 | readonly useQuickAdd?: () => ( | ||
| 306 | input: IntegrationQuickAddInput, | ||
| 307 | ) => Promise<IntegrationQuickAddResult>; | ||
| 248 | } | 308 | } |
| 249 | 309 | ||
| 250 | // --------------------------------------------------------------------------- | 310 | // --------------------------------------------------------------------------- |
| @@ -612,6 +612,10 @@ export interface IntegrationPreset { · 611 unmodified lines | |||
| 612 | readonly specOverrides?: readonly unknown[]; | 612 | readonly specOverrides?: readonly unknown[]; |
| 613 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; | 613 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; |
| 614 | readonly healthCheck?: HealthCheckSpec; | 614 | readonly healthCheck?: HealthCheckSpec; |
| 615 | /** The public registry lists this product: the picker shows the registry's | ||
| 616 | * card, and the preset's knowledge rides quick add instead. A custom | ||
| 617 | * deployment preset leaves this unset and keeps its own card. */ | ||
| 618 | readonly registryListed?: boolean; | ||
| 615 | readonly transport?: "remote" | "stdio"; | 619 | readonly transport?: "remote" | "stdio"; |
| 616 | readonly command?: string; | 620 | readonly command?: string; |
| 617 | readonly args?: readonly string[]; | 621 | readonly args?: readonly string[]; |
| @@ -1,5 +1,19 @@ | |||
| 1 | # @executor-js/vite-plugin | 1 | # @executor-js/vite-plugin |
| 2 | 2 | ||
| 3 | ## 0.0.66 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/sdk@1.6.6 | ||
| 9 | |||
| 10 | ## 0.0.65 | ||
| 11 | |||
| 12 | ### Patch Changes | ||
| 13 | |||
| 14 | - Updated dependencies []: | ||
| 15 | - @executor-js/sdk@1.6.5 | ||
| 16 | |||
| 3 | ## 0.0.64 | 17 | ## 0.0.64 |
| 4 | 18 | ||
| 5 | ### Patch Changes | 19 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/vite-plugin", | 2 | "name": "@executor-js/vite-plugin", |
| 3 | "version": "0.0.64", | 3 | "version": "0.0.66", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/vite-plugin", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/vite-plugin", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -1,5 +1,25 @@ | |||
| 1 | # @executor-js/cloudflare | 1 | # @executor-js/cloudflare |
| 2 | 2 | ||
| 3 | ## 0.0.48 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d)]: | ||
| 8 | - @executor-js/execution@1.6.6 | ||
| 9 | - @executor-js/api@1.4.69 | ||
| 10 | - @executor-js/host-mcp@1.4.4 | ||
| 11 | - @executor-js/sdk@1.6.6 | ||
| 12 | |||
| 13 | ## 0.0.47 | ||
| 14 | |||
| 15 | ### Patch Changes | ||
| 16 | |||
| 17 | - Updated dependencies []: | ||
| 18 | - @executor-js/sdk@1.6.5 | ||
| 19 | - @executor-js/execution@1.6.5 | ||
| 20 | - @executor-js/api@1.4.68 | ||
| 21 | - @executor-js/host-mcp@1.4.4 | ||
| 22 | |||
| 3 | ## 0.0.46 | 23 | ## 0.0.46 |
| 4 | 24 | ||
| 5 | ### Patch Changes | 25 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/cloudflare", | 2 | "name": "@executor-js/cloudflare", |
| 3 | "version": "0.0.46", | 3 | "version": "0.0.48", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "exports": { | 6 | "exports": { |
| @@ -1,5 +1,21 @@ | |||
| 1 | # @executor-js/mcp-apps-shell | 1 | # @executor-js/mcp-apps-shell |
| 2 | 2 | ||
| 3 | ## 1.4.17 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: | ||
| 8 | - @executor-js/react@1.4.69 | ||
| 9 | - @executor-js/runtime-quickjs@1.6.6 | ||
| 10 | |||
| 11 | ## 1.4.16 | ||
| 12 | |||
| 13 | ### Patch Changes | ||
| 14 | |||
| 15 | - Updated dependencies []: | ||
| 16 | - @executor-js/runtime-quickjs@1.6.5 | ||
| 17 | - @executor-js/react@1.4.68 | ||
| 18 | |||
| 3 | ## 1.4.15 | 19 | ## 1.4.15 |
| 4 | 20 | ||
| 5 | ### Patch Changes | 21 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/mcp-apps-shell", | 2 | "name": "@executor-js/mcp-apps-shell", |
| 3 | "version": "1.4.15", | 3 | "version": "1.4.17", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "exports": { | 6 | "exports": { |
| @@ -1973,3 +1973,64 @@ describe("MCP host server — hang-visibility tracing", () => { · 1972 unmodified lines | |||
| 1973 | ); | 1973 | ); |
| 1974 | }); | 1974 | }); |
| 1975 | }); | 1975 | }); |
| 1976 | |||
| 1977 | // Pins that formatting a completed execution for MCP walks the result value | ||
| 1978 | // exactly once (`formatExecuteResult`'s pretty print), with or without emit() | ||
| 1979 | // output — the with-output and without-output branches are alternatives, never | ||
| 1980 | // stacked. A `toJSON` probe counts full `JSON.stringify` walks of the value. | ||
| 1981 | describe("formatMcpExecutionOutcome serialization cost", () => { | ||
| 1982 | const instrumented = () => { | ||
| 1983 | let walks = 0; | ||
| 1984 | const probe = { | ||
| 1985 | toJSON: () => { | ||
| 1986 | walks += 1; | ||
| 1987 | return "probe"; | ||
| 1988 | }, | ||
| 1989 | }; | ||
| 1990 | return { value: { data: [1, 2, 3], probe }, walks: () => walks }; | ||
| 1991 | }; | ||
| 1992 | |||
| 1993 | it("stringifies the result value once for a plain completed outcome", () => { | ||
| 1994 | const fixture = instrumented(); | ||
| 1995 | const outcome: ExecutionResult = { | ||
| 1996 | status: "completed", | ||
| 1997 | result: { result: fixture.value, logs: [] }, | ||
| 1998 | }; | ||
| 1999 | |||
| 2000 | const result = formatMcpExecutionOutcome(outcome); | ||
| 2001 | |||
| 2002 | expect(fixture.walks()).toBe(1); | ||
| 2003 | const first = result.content[0]; | ||
| 2004 | expectDefined(first); | ||
| 2005 | expect(first).toEqual({ | ||
| 2006 | type: "text", | ||
| 2007 | text: JSON.stringify(fixture.value, null, 2), | ||
| 2008 | }); | ||
| 2009 | expectDefined(result.structuredContent); | ||
| 2010 | expect(result.structuredContent["result"]).toBe(fixture.value); | ||
| 2011 | // The identity probe above walked the value once more; discount it. | ||
| 2012 | expect(fixture.walks()).toBe(2); | ||
| 2013 | }); | ||
| 2014 | |||
| 2015 | it("stringifies the result value once when emit() output is present", () => { | ||
| 2016 | const fixture = instrumented(); | ||
| 2017 | const outcome: ExecutionResult = { | ||
| 2018 | status: "completed", | ||
| 2019 | result: { | ||
| 2020 | result: fixture.value, | ||
| 2021 | logs: [], | ||
| 2022 | output: [{ type: "content", content: { type: "text", text: "emitted" } }], | ||
| 2023 | }, | ||
| 2024 | }; | ||
| 2025 | |||
| 2026 | const result = formatMcpExecutionOutcome(outcome); | ||
| 2027 | |||
| 2028 | expect(fixture.walks()).toBe(1); | ||
| 2029 | expect(result.content[0]).toEqual({ type: "text", text: "emitted" }); | ||
| 2030 | const returned = result.content[1]; | ||
| 2031 | expectDefined(returned); | ||
| 2032 | expect(returned.type).toBe("text"); | ||
| 2033 | if (returned.type !== "text") return; | ||
| 2034 | expect(returned.text).toContain('"data"'); | ||
| 2035 | }); | ||
| 2036 | }); | ||
| @@ -1,5 +1,9 @@ | |||
| 1 | # @executor-js/codemode-core | 1 | # @executor-js/codemode-core |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ## 1.6.5 | ||
| 6 | |||
| 3 | ## 1.6.4 | 7 | ## 1.6.4 |
| 4 | 8 | ||
| 5 | ## 1.6.3 | 9 | ## 1.6.3 |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/codemode-core", | 2 | "name": "@executor-js/codemode-core", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/core", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/core", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -1,5 +1,19 @@ | |||
| 1 | # @executor-js/runtime-quickjs | 1 | # @executor-js/runtime-quickjs |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/codemode-core@1.6.6 | ||
| 9 | |||
| 10 | ## 1.6.5 | ||
| 11 | |||
| 12 | ### Patch Changes | ||
| 13 | |||
| 14 | - Updated dependencies []: | ||
| 15 | - @executor-js/codemode-core@1.6.5 | ||
| 16 | |||
| 3 | ## 1.6.4 | 17 | ## 1.6.4 |
| 4 | 18 | ||
| 5 | ### Patch Changes | 19 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/runtime-quickjs", | 2 | "name": "@executor-js/runtime-quickjs", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/runtime-quickjs", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/runtime-quickjs", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -1,5 +1,19 @@ | |||
| 1 | # @executor-js/runtime-workerd-subprocess | 1 | # @executor-js/runtime-workerd-subprocess |
| 2 | 2 | ||
| 3 | ## 0.0.21 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/codemode-core@1.6.6 | ||
| 9 | |||
| 10 | ## 0.0.20 | ||
| 11 | |||
| 12 | ### Patch Changes | ||
| 13 | |||
| 14 | - Updated dependencies []: | ||
| 15 | - @executor-js/codemode-core@1.6.5 | ||
| 16 | |||
| 3 | ## 0.0.19 | 17 | ## 0.0.19 |
| 4 | 18 | ||
| 5 | ### Patch Changes | 19 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/runtime-workerd-subprocess", | 2 | "name": "@executor-js/runtime-workerd-subprocess", |
| 3 | "version": "0.0.19", | 3 | "version": "0.0.21", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "exports": { | 6 | "exports": { |
| @@ -0,0 +1,11 @@ | |||
| 1 | # @executor-js/onboarding-demo | ||
| 2 | |||
| 3 | ## 0.0.1 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed), [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: | ||
| 8 | - @executor-js/plugin-mcp@1.6.6 | ||
| 9 | - @executor-js/react@1.4.69 | ||
| 10 | - @executor-js/plugin-openapi@1.6.6 | ||
| 11 | - @executor-js/sdk@1.6.6 | ||
| @@ -0,0 +1,53 @@ | |||
| 1 | # Onboarding demo | ||
| 2 | |||
| 3 | A standalone reproduction of executor's **current** add-integration flow, from | ||
| 4 | "the CLI is connected" to "the integration has a working connection". No API, no | ||
| 5 | auth, no tenant — it runs off `src/fixtures.ts` and the public integrations.sh | ||
| 6 | endpoints. | ||
| 7 | |||
| 8 | ``` | ||
| 9 | bun run --cwd packages/onboarding-demo dev # http://localhost:5199 | ||
| 10 | ``` | ||
| 11 | |||
| 12 | Every screen is addressable: `#integrations-empty`, `#connect-dialog`, | ||
| 13 | `#add-openapi`, `#detail-accounts`, `#add-account-credential`, | ||
| 14 | `#add-account-place`, `#oauth-stuck`, `#integrations-populated`. `[` and `]` | ||
| 15 | step through them. `src/flow.ts` records, per screen, the route and source | ||
| 16 | component it was reproduced from plus the first-run reactions against it. | ||
| 17 | |||
| 18 | A floating Current / Reworked control in the bottom-right corner switches | ||
| 19 | between the two flows. | ||
| 20 | |||
| 21 | ## Reworked flow | ||
| 22 | |||
| 23 | `#reworked` — the picker, `#reworked/installed`, `#reworked/custom`, | ||
| 24 | `#reworked/<domain>`, `#reworked/<domain>/auth`. Browse/Installed carries | ||
| 25 | navigation within the picker; the console sidebar stays as it is today. A deep link to a domain you have not added in this session | ||
| 26 | says so rather than rendering an empty screen — the prototype keeps no state | ||
| 27 | across reloads. | ||
| 28 | |||
| 29 | Registry picks and custom URLs converge: both become an added integration with | ||
| 30 | one `default` account reading Needs auth. Only the detail header differs, naming | ||
| 31 | where the definition came from. | ||
| 32 | |||
| 33 | ## Fidelity | ||
| 34 | |||
| 35 | - The curated preset list is imported from the real plugin modules | ||
| 36 | (`@executor-js/plugin-openapi/presets`, `@executor-js/plugin-mcp/presets`), | ||
| 37 | so it is the list the console shows, in the console's order. | ||
| 38 | - UI primitives and design tokens are imported from `@executor-js/react`, not | ||
| 39 | copied — the reproduction uses the same buttons, dialogs, card stacks, tabs | ||
| 40 | and tokens as the console. | ||
| 41 | - The catalog search calls the real `integrations.sh/api/search` with the same | ||
| 42 | 250ms debounce and 2-character minimum. | ||
| 43 | - Screen structure and copy are transcribed from the source components named in | ||
| 44 | the inspector. | ||
| 45 | |||
| 46 | What is faked: the workspace's own data (integrations, connections, tools) and | ||
| 47 | every mutation. Adding an integration waits and moves on; nothing persists. | ||
| 48 | |||
| 49 | `fixtures.ts` also exposes two integrations.sh endpoints the console does **not** | ||
| 50 | use today — `fetchCatalogDomains` (the whole ~3.4k-domain popularity-sorted | ||
| 51 | registry) and `fetchSurfaceCredentials` (per-domain credential label, the URL | ||
| 52 | that mints the key, and setup instructions). They are unused by the | ||
| 53 | reproduction and present as raw material for the rework. | ||
| @@ -0,0 +1,18 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="utf-8" /> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| 6 | <title>Executor onboarding — current flow</title> | ||
| 7 | <link rel="preconnect" href="https://fonts.googleapis.com" /> | ||
| 8 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | ||
| 9 | <link | ||
| 10 | rel="stylesheet" | ||
| 11 | href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap" | ||
| 12 | /> | ||
| 13 | </head> | ||
| 14 | <body> | ||
| 15 | <div id="root"></div> | ||
| 16 | <script type="module" src="/src/main.tsx"></script> | ||
| 17 | </body> | ||
| 18 | </html> | ||
| @@ -0,0 +1,32 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor-js/onboarding-demo", | ||
| 3 | "version": "0.0.1", | ||
| 4 | "private": true, | ||
| 5 | "type": "module", | ||
| 6 | "scripts": { | ||
| 7 | "dev": "vite", | ||
| 8 | "build": "vite build", | ||
| 9 | "preview": "vite preview", | ||
| 10 | "typecheck": "tsgo --noEmit" | ||
| 11 | }, | ||
| 12 | "dependencies": { | ||
| 13 | "@executor-js/plugin-mcp": "workspace:*", | ||
| 14 | "@executor-js/plugin-openapi": "workspace:*", | ||
| 15 | "@executor-js/react": "workspace:*", | ||
| 16 | "@executor-js/sdk": "workspace:*", | ||
| 17 | "lucide-react": "^1.7.0", | ||
| 18 | "react": "catalog:", | ||
| 19 | "react-dom": "catalog:", | ||
| 20 | "sonner": "^2.0.7", | ||
| 21 | "tldts": "^7.0.28" | ||
| 22 | }, | ||
| 23 | "devDependencies": { | ||
| 24 | "@tailwindcss/vite": "catalog:", | ||
| 25 | "@types/react": "catalog:", | ||
| 26 | "@types/react-dom": "catalog:", | ||
| 27 | "@vitejs/plugin-react": "catalog:", | ||
| 28 | "tailwindcss": "catalog:", | ||
| 29 | "typescript": "catalog:", | ||
| 30 | "vite": "catalog:" | ||
| 31 | } | ||
| 32 | } | ||
| @@ -0,0 +1,202 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 2 | import { cn } from "@executor-js/react/lib/utils"; | ||
| 3 | import { Shell } from "./shell"; | ||
| 4 | import { IntegrationsPage } from "./screens/integrations-page"; | ||
| 5 | import { ConnectDialog } from "./screens/connect-dialog"; | ||
| 6 | import { AddOpenApiPage } from "./screens/add-openapi-page"; | ||
| 7 | import { IntegrationDetailPage } from "./screens/integration-detail"; | ||
| 8 | import { AddAccountModal } from "./screens/add-account-modal"; | ||
| 9 | import { flowSteps, type StepId } from "./flow"; | ||
| 10 | import { stepState, type AccountModal, type ScreenView } from "./step-state"; | ||
| 11 | import { NextFlow } from "./next-flow"; | ||
| 12 | import { | ||
| 13 | gmailAuthMethods, | ||
| 14 | gmailIntegration, | ||
| 15 | posthogAuthMethods, | ||
| 16 | posthogIntegration, | ||
| 17 | seededIntegrations, | ||
| 18 | type DemoIntegration, | ||
| 19 | type DemoPreset, | ||
| 20 | } from "./fixtures"; | ||
| 21 | |||
| 22 | const posthogPreset: DemoPreset = { | ||
| 23 | id: "posthog", | ||
| 24 | name: "PostHog", | ||
| 25 | summary: "Product analytics, events, feature flags, and insights.", | ||
| 26 | icon: "https://integrations.sh/logo/posthog.com", | ||
| 27 | url: "https://raw.githubusercontent.com/PostHog/posthog/master/openapi/openapi.json", | ||
| 28 | pluginKey: "openapi", | ||
| 29 | pluginLabel: "OpenAPI", | ||
| 30 | }; | ||
| 31 | |||
| 32 | const stepFromHash = (): StepId => { | ||
| 33 | const hash = globalThis.location?.hash.replace(/^#/, "") ?? ""; | ||
| 34 | return flowSteps.some((step) => step.id === hash) ? (hash as StepId) : "integrations-empty"; | ||
| 35 | }; | ||
| 36 | |||
| 37 | function CurrentFlowApp() { | ||
| 38 | // Each screen is addressable as `#<step-id>`, so a specific screen can be | ||
| 39 | // linked to directly rather than clicked toward. `[` and `]` step through. | ||
| 40 | const [stepId, setStepId] = useState<StepId>(stepFromHash); | ||
| 41 | const [overrides, setOverrides] = useState<{ | ||
| 42 | readonly integrations?: readonly DemoIntegration[]; | ||
| 43 | readonly connectOpen?: boolean; | ||
| 44 | readonly accountModal?: AccountModal; | ||
| 45 | readonly view?: ScreenView; | ||
| 46 | }>({}); | ||
| 47 | |||
| 48 | const base = useMemo(() => stepState[stepId], [stepId]); | ||
| 49 | const state = { ...base, ...overrides }; | ||
| 50 | |||
| 51 | const goToStep = useCallback((id: StepId) => { | ||
| 52 | setStepId(id); | ||
| 53 | setOverrides({}); | ||
| 54 | if (globalThis.location) globalThis.location.hash = id; | ||
| 55 | }, []); | ||
| 56 | |||
| 57 | useEffect(() => { | ||
| 58 | const onHashChange = () => { | ||
| 59 | setStepId(stepFromHash()); | ||
| 60 | setOverrides({}); | ||
| 61 | }; | ||
| 62 | globalThis.addEventListener("hashchange", onHashChange); | ||
| 63 | return () => globalThis.removeEventListener("hashchange", onHashChange); | ||
| 64 | }, []); | ||
| 65 | |||
| 66 | useEffect(() => { | ||
| 67 | const onKey = (event: KeyboardEvent) => { | ||
| 68 | if (event.key !== "[" && event.key !== "]") return; | ||
| 69 | const index = flowSteps.findIndex((candidate) => candidate.id === stepId); | ||
| 70 | const next = event.key === "]" ? index + 1 : index - 1; | ||
| 71 | const target = flowSteps[next]; | ||
| 72 | if (target) goToStep(target.id); | ||
| 73 | }; | ||
| 74 | globalThis.addEventListener("keydown", onKey); | ||
| 75 | return () => globalThis.removeEventListener("keydown", onKey); | ||
| 76 | }, [stepId, goToStep]); | ||
| 77 | |||
| 78 | const integrations = state.integrations ?? seededIntegrations; | ||
| 79 | |||
| 80 | return ( | ||
| 81 | <> | ||
| 82 | <Shell integrations={integrations}> | ||
| 83 | {state.view === "integrations" && ( | ||
| 84 | <IntegrationsPage | ||
| 85 | integrations={integrations} | ||
| 86 | onConnect={() => setOverrides((prev) => ({ ...prev, connectOpen: true }))} | ||
| 87 | onOpenIntegration={() => setOverrides((prev) => ({ ...prev, view: "detail" }))} | ||
| 88 | /> | ||
| 89 | )} | ||
| 90 | |||
| 91 | {state.view === "add" && ( | ||
| 92 | <AddOpenApiPage | ||
| 93 | preset={posthogPreset} | ||
| 94 | onCancel={() => goToStep("integrations-empty")} | ||
| 95 | onComplete={() => goToStep("detail-accounts")} | ||
| 96 | /> | ||
| 97 | )} | ||
| 98 | |||
| 99 | {state.view === "detail" && ( | ||
| 100 | <IntegrationDetailPage | ||
| 101 | integration={ | ||
| 102 | state.accountModal === "oauth-stuck" ? gmailIntegration : posthogIntegration | ||
| 103 | } | ||
| 104 | onAddConnection={() => | ||
| 105 | setOverrides((prev) => ({ ...prev, accountModal: "credential" })) | ||
| 106 | } | ||
| 107 | /> | ||
| 108 | )} | ||
| 109 | </Shell> | ||
| 110 | |||
| 111 | <ConnectDialog | ||
| 112 | open={state.connectOpen === true} | ||
| 113 | onOpenChange={(open) => setOverrides((prev) => ({ ...prev, connectOpen: open }))} | ||
| 114 | onPickPreset={() => goToStep("add-openapi")} | ||
| 115 | onPickPlugin={() => goToStep("add-openapi")} | ||
| 116 | onPickCatalogEntry={() => goToStep("add-openapi")} | ||
| 117 | /> | ||
| 118 | |||
| 119 | {state.accountModal !== "none" && ( | ||
| 120 | <AddAccountModal | ||
| 121 | // Remount per variant: a modal that stays mounted across a step | ||
| 122 | // change keeps the previous step's wizard position. | ||
| 123 | key={state.accountModal} | ||
| 124 | open | ||
| 125 | onOpenChange={(open) => | ||
| 126 | setOverrides((prev) => ({ ...prev, accountModal: open ? prev.accountModal : "none" })) | ||
| 127 | } | ||
| 128 | integrationName={state.accountModal === "oauth-stuck" ? "Gmail" : "PostHog"} | ||
| 129 | methods={state.accountModal === "oauth-stuck" ? gmailAuthMethods : posthogAuthMethods} | ||
| 130 | initialStep={state.accountModal === "place" ? "place" : "validate"} | ||
| 131 | stuckConnecting={state.accountModal === "oauth-stuck"} | ||
| 132 | onAdded={() => goToStep("integrations-populated")} | ||
| 133 | /> | ||
| 134 | )} | ||
| 135 | </> | ||
| 136 | ); | ||
| 137 | } | ||
| 138 | |||
| 139 | // --------------------------------------------------------------------------- | ||
| 140 | // Mode switch | ||
| 141 | // | ||
| 142 | // Both flows run in the same console chrome, so the comparison is about the | ||
| 143 | // flow rather than the styling. The switch is a small floating control rather | ||
| 144 | // than a panel: it is scaffolding for looking at the prototype, and it should | ||
| 145 | // not take width away from the thing being looked at. | ||
| 146 | // --------------------------------------------------------------------------- | ||
| 147 | |||
| 148 | type Mode = "current" | "next"; | ||
| 149 | |||
| 150 | function ModeSwitch(props: { readonly mode: Mode; readonly onChange: (mode: Mode) => void }) { | ||
| 151 | const option = (mode: Mode, label: string) => ( | ||
| 152 | // oxlint-disable-next-line react/forbid-elements | ||
| 153 | <button | ||
| 154 | key={mode} | ||
| 155 | type="button" | ||
| 156 | onClick={() => props.onChange(mode)} | ||
| 157 | className={cn( | ||
| 158 | "rounded-full px-2.5 py-1 text-xs font-medium transition-colors", | ||
| 159 | props.mode === mode | ||
| 160 | ? "bg-foreground text-background" | ||
| 161 | : "text-muted-foreground hover:text-foreground", | ||
| 162 | )} | ||
| 163 | > | ||
| 164 | {label} | ||
| 165 | </button> | ||
| 166 | ); | ||
| 167 | return ( | ||
| 168 | <div className="fixed bottom-4 right-4 z-50 flex items-center gap-0.5 rounded-full border border-border bg-card/95 p-1 shadow-lg backdrop-blur-sm"> | ||
| 169 | {option("current", "Current")} | ||
| 170 | {option("next", "Reworked")} | ||
| 171 | </div> | ||
| 172 | ); | ||
| 173 | } | ||
| 174 | |||
| 175 | const modeFromHash = (): Mode => { | ||
| 176 | const hash = globalThis.location?.hash ?? ""; | ||
| 177 | return hash.startsWith("#reworked") || hash.startsWith("#next") ? "next" : "current"; | ||
| 178 | }; | ||
| 179 | |||
| 180 | export function App() { | ||
| 181 | const [mode, setMode] = useState<Mode>(modeFromHash); | ||
| 182 | |||
| 183 | useEffect(() => { | ||
| 184 | const onHashChange = () => setMode(modeFromHash()); | ||
| 185 | globalThis.addEventListener("hashchange", onHashChange); | ||
| 186 | return () => globalThis.removeEventListener("hashchange", onHashChange); | ||
| 187 | }, []); | ||
| 188 | |||
| 189 | const changeMode = (next: Mode) => { | ||
| 190 | setMode(next); | ||
| 191 | if (globalThis.location) { | ||
| 192 | globalThis.location.hash = next === "next" ? "reworked" : "integrations-empty"; | ||
| 193 | } | ||
| 194 | }; | ||
| 195 | |||
| 196 | return ( | ||
| 197 | <div className="flex h-dvh w-full overflow-hidden bg-background text-foreground"> | ||
| 198 | {mode === "next" ? <NextFlow /> : <CurrentFlowApp />} | ||
| 199 | <ModeSwitch mode={mode} onChange={changeMode} /> | ||
| 200 | </div> | ||
| 201 | ); | ||
| 202 | } | ||
| @@ -0,0 +1,163 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // The catalog behind the reworked picker. | ||
| 3 | // | ||
| 4 | // Everything here except the category is real registry data, loaded live from | ||
| 5 | // integrations.sh: ~3.4k domains, popularity-sorted, with icons, descriptions | ||
| 6 | // and the formats each one exposes. | ||
| 7 | // | ||
| 8 | // CATEGORY IS A STAND-IN. integrations.sh has a `categories` field, but only | ||
| 9 | // the 13 hand-curated entries populate it — the other ~3,365 domains have none. | ||
| 10 | // A category rail like the reference picker's therefore needs the catalog | ||
| 11 | // categorised first (the "leverage AI for the generation" step). The keyword | ||
| 12 | // pass below exists so the rail is real enough to design against; it is not a | ||
| 13 | // taxonomy and should not survive into the product. | ||
| 14 | // --------------------------------------------------------------------------- | ||
| 15 | |||
| 16 | import { fetchCatalogDomains, type CatalogDomain } from "./fixtures"; | ||
| 17 | |||
| 18 | export type Category = | ||
| 19 | | "Featured" | ||
| 20 | | "Inbox & calendar" | ||
| 21 | | "Docs & files" | ||
| 22 | | "Developer tools" | ||
| 23 | | "Analytics" | ||
| 24 | | "Payments" | ||
| 25 | | "Support" | ||
| 26 | | "Project management" | ||
| 27 | | "Infrastructure" | ||
| 28 | | "Everything else"; | ||
| 29 | |||
| 30 | export const CATEGORY_ORDER: readonly Category[] = [ | ||
| 31 | "Featured", | ||
| 32 | "Inbox & calendar", | ||
| 33 | "Docs & files", | ||
| 34 | "Developer tools", | ||
| 35 | "Analytics", | ||
| 36 | "Payments", | ||
| 37 | "Support", | ||
| 38 | "Project management", | ||
| 39 | "Infrastructure", | ||
| 40 | "Everything else", | ||
| 41 | ]; | ||
| 42 | |||
| 43 | const KEYWORDS: readonly (readonly [Category, readonly string[]])[] = [ | ||
| 44 | ["Inbox & calendar", ["email", "mail", "calendar", "inbox", "meeting", "scheduling"]], | ||
| 45 | ["Docs & files", ["document", "file", "drive", "storage", "note", "wiki", "knowledge"]], | ||
| 46 | ["Analytics", ["analytic", "metric", "tracking", "telemetry", "dashboard", "insight"]], | ||
| 47 | ["Payments", ["payment", "invoice", "billing", "subscription", "checkout", "payout"]], | ||
| 48 | ["Support", ["support", "ticket", "helpdesk", "customer", "chat"]], | ||
| 49 | ["Project management", ["issue", "task", "project", "sprint", "roadmap", "backlog"]], | ||
| 50 | [ | ||
| 51 | "Infrastructure", | ||
| 52 | ["deploy", "server", "cluster", "database", "kubernetes", "cloud", "hosting", "dns"], | ||
| 53 | ], | ||
| 54 | ["Developer tools", ["repo", "commit", "pull request", "ci", "build", "code", "sdk", "api"]], | ||
| 55 | ]; | ||
| 56 | |||
| 57 | /** Best-effort bucket from the description. Deliberately crude — see the file | ||
| 58 | * header. Anything unmatched lands in "Everything else" rather than being | ||
| 59 | * guessed at. */ | ||
| 60 | const deriveCategory = (entry: CatalogDomain): Category => { | ||
| 61 | const haystack = `${entry.domain} ${entry.description}`.toLowerCase(); | ||
| 62 | for (const [category, words] of KEYWORDS) { | ||
| 63 | if (words.some((word) => haystack.includes(word))) return category; | ||
| 64 | } | ||
| 65 | return "Everything else"; | ||
| 66 | }; | ||
| 67 | |||
| 68 | /** Domains the reference picker would call "Featured": the ones executor | ||
| 69 | * already ships a curated preset for, which are also the ones most people | ||
| 70 | * arrive wanting. */ | ||
| 71 | const FEATURED_DOMAINS: readonly string[] = [ | ||
| 72 | "google.com", | ||
| 73 | "github.com", | ||
| 74 | "linear.app", | ||
| 75 | "notion.com", | ||
| 76 | "slack.com", | ||
| 77 | "stripe.com", | ||
| 78 | "figma.com", | ||
| 79 | "posthog.com", | ||
| 80 | "sentry.io", | ||
| 81 | "vercel.com", | ||
| 82 | "resend.com", | ||
| 83 | "asana.com", | ||
| 84 | ]; | ||
| 85 | |||
| 86 | export interface CatalogItem { | ||
| 87 | readonly domain: string; | ||
| 88 | readonly name: string; | ||
| 89 | readonly description: string; | ||
| 90 | readonly icon: string; | ||
| 91 | readonly formats: readonly string[]; | ||
| 92 | readonly category: Category; | ||
| 93 | readonly featured: boolean; | ||
| 94 | readonly popularity: number; | ||
| 95 | } | ||
| 96 | |||
| 97 | /** Title-case a domain into something that reads like a product name. | ||
| 98 | * `linear.app` → `Linear`, `google.com` → `Google`. */ | ||
| 99 | const displayName = (domain: string): string => { | ||
| 100 | const stem = domain.split(".")[0] ?? domain; | ||
| 101 | return stem.charAt(0).toUpperCase() + stem.slice(1); | ||
| 102 | }; | ||
| 103 | |||
| 104 | export const loadCatalog = async (): Promise<readonly CatalogItem[]> => { | ||
| 105 | const domains = await fetchCatalogDomains(); | ||
| 106 | return domains.map((entry): CatalogItem => { | ||
| 107 | const featured = FEATURED_DOMAINS.includes(entry.domain); | ||
| 108 | return { | ||
| 109 | domain: entry.domain, | ||
| 110 | name: displayName(entry.domain), | ||
| 111 | description: entry.description, | ||
| 112 | icon: entry.icon, | ||
| 113 | formats: Object.keys(entry.formats ?? {}), | ||
| 114 | category: featured ? "Featured" : deriveCategory(entry), | ||
| 115 | featured, | ||
| 116 | popularity: entry.popularity, | ||
| 117 | }; | ||
| 118 | }); | ||
| 119 | }; | ||
| 120 | |||
| 121 | export const searchItems = ( | ||
| 122 | items: readonly CatalogItem[], | ||
| 123 | query: string, | ||
| 124 | ): readonly CatalogItem[] => { | ||
| 125 | const q = query.trim().toLowerCase(); | ||
| 126 | if (q.length === 0) return items; | ||
| 127 | return items.filter((item) => | ||
| 128 | `${item.domain} ${item.name} ${item.description}`.toLowerCase().includes(q), | ||
| 129 | ); | ||
| 130 | }; | ||
| 131 | |||
| 132 | // --------------------------------------------------------------------------- | ||
| 133 | // Added integrations | ||
| 134 | // | ||
| 135 | // A registry pick and a hand-pasted URL become the SAME kind of thing the | ||
| 136 | // moment they're added. Only `source` differs, and it only affects what the | ||
| 137 | // detail screen can say about where the definition came from — never how you | ||
| 138 | // authenticate it or where it lives. | ||
| 139 | // --------------------------------------------------------------------------- | ||
| 140 | |||
| 141 | export interface AddedIntegration { | ||
| 142 | readonly domain: string; | ||
| 143 | readonly name: string; | ||
| 144 | readonly description: string; | ||
| 145 | readonly icon: string; | ||
| 146 | readonly formats: readonly string[]; | ||
| 147 | readonly source: "registry" | "custom"; | ||
| 148 | /** Custom only: the endpoint or spec URL the user pasted. */ | ||
| 149 | readonly url?: string; | ||
| 150 | } | ||
| 151 | |||
| 152 | export const fromCatalogItem = (item: CatalogItem): AddedIntegration => ({ | ||
| 153 | domain: item.domain, | ||
| 154 | name: item.name, | ||
| 155 | description: item.description, | ||
| 156 | icon: item.icon, | ||
| 157 | formats: item.formats, | ||
| 158 | source: "registry", | ||
| 159 | }); | ||
| 160 | |||
| 161 | /** integrations.sh serves a favicon for any domain, registry entry or not, so | ||
| 162 | * a custom integration still gets a real logo. */ | ||
| 163 | export const logoFor = (domain: string): string => `https://integrations.sh/logo/${domain}`; | ||
| @@ -0,0 +1,240 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Everything the reproduction renders from. | ||
| 3 | // | ||
| 4 | // Presets come from the REAL plugin modules, so the curated list here is the | ||
| 5 | // same list the console shows. Connections, tools and auth methods are static | ||
| 6 | // stand-ins for the API reads the console makes. The integrations.sh calls are | ||
| 7 | // the genuine public endpoints — that catalog is already client-callable, which | ||
| 8 | // is exactly why it is the obvious raw material for the rework. | ||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | |||
| 11 | import { openApiPresets, type OpenApiPreset } from "@executor-js/plugin-openapi/presets"; | ||
| 12 | import { mcpPresets, type McpPreset } from "@executor-js/plugin-mcp/presets"; | ||
| 13 | |||
| 14 | export type PluginKey = "openapi" | "mcp" | "graphql" | "google"; | ||
| 15 | |||
| 16 | export interface DemoPreset { | ||
| 17 | readonly id: string; | ||
| 18 | readonly name: string; | ||
| 19 | readonly summary: string; | ||
| 20 | readonly icon?: string; | ||
| 21 | readonly url?: string; | ||
| 22 | readonly pluginKey: PluginKey; | ||
| 23 | readonly pluginLabel: string; | ||
| 24 | } | ||
| 25 | |||
| 26 | const fromOpenApi = (preset: OpenApiPreset): DemoPreset => ({ | ||
| 27 | id: preset.id, | ||
| 28 | name: preset.name, | ||
| 29 | summary: preset.summary, | ||
| 30 | ...(preset.icon ? { icon: preset.icon } : {}), | ||
| 31 | ...(preset.url ? { url: preset.url } : {}), | ||
| 32 | pluginKey: "openapi", | ||
| 33 | pluginLabel: "OpenAPI", | ||
| 34 | }); | ||
| 35 | |||
| 36 | const fromMcp = (preset: McpPreset): DemoPreset => ({ | ||
| 37 | id: preset.id, | ||
| 38 | name: preset.name, | ||
| 39 | summary: preset.summary, | ||
| 40 | ...(preset.icon ? { icon: preset.icon } : {}), | ||
| 41 | ...("url" in preset && preset.url ? { url: preset.url } : {}), | ||
| 42 | pluginKey: "mcp", | ||
| 43 | pluginLabel: "MCP", | ||
| 44 | }); | ||
| 45 | |||
| 46 | /** The console's "Popular integrations" list, in the console's own order: | ||
| 47 | * every loaded plugin's presets, concatenated plugin by plugin. */ | ||
| 48 | export const curatedPresets: readonly DemoPreset[] = [ | ||
| 49 | ...openApiPresets.map(fromOpenApi), | ||
| 50 | ...mcpPresets.map(fromMcp), | ||
| 51 | ]; | ||
| 52 | |||
| 53 | /** The plugin chips under "Or add manually". */ | ||
| 54 | export const integrationPlugins: readonly { readonly key: PluginKey; readonly label: string }[] = [ | ||
| 55 | { key: "openapi", label: "OpenAPI" }, | ||
| 56 | { key: "mcp", label: "MCP" }, | ||
| 57 | { key: "graphql", label: "GraphQL" }, | ||
| 58 | { key: "google", label: "Google" }, | ||
| 59 | ]; | ||
| 60 | |||
| 61 | // --------------------------------------------------------------------------- | ||
| 62 | // Workspace state — what a brand-new tenant has, which is nothing | ||
| 63 | // --------------------------------------------------------------------------- | ||
| 64 | |||
| 65 | export interface DemoIntegration { | ||
| 66 | readonly slug: string; | ||
| 67 | readonly name: string; | ||
| 68 | readonly kind: PluginKey; | ||
| 69 | readonly icon?: string; | ||
| 70 | readonly toolCount: number; | ||
| 71 | } | ||
| 72 | |||
| 73 | export const seededIntegrations: readonly DemoIntegration[] = []; | ||
| 74 | |||
| 75 | /** What the workspace looks like after the user adds PostHog — the state Theo | ||
| 76 | * reached before hunting for the key field. */ | ||
| 77 | export const posthogIntegration: DemoIntegration = { | ||
| 78 | slug: "posthog", | ||
| 79 | name: "PostHog", | ||
| 80 | kind: "openapi", | ||
| 81 | icon: "https://integrations.sh/logo/posthog.com", | ||
| 82 | toolCount: 0, | ||
| 83 | }; | ||
| 84 | |||
| 85 | /** The OAuth-only integration, for the step where the popup dies. */ | ||
| 86 | export const gmailIntegration: DemoIntegration = { | ||
| 87 | slug: "gmail", | ||
| 88 | name: "Gmail", | ||
| 89 | kind: "google", | ||
| 90 | icon: "https://integrations.sh/logo/google.com", | ||
| 91 | toolCount: 0, | ||
| 92 | }; | ||
| 93 | |||
| 94 | // --------------------------------------------------------------------------- | ||
| 95 | // Auth methods for the add-connection modal | ||
| 96 | // | ||
| 97 | // Shape mirrors `AuthMethod` in packages/react/src/lib/auth-placements.tsx. | ||
| 98 | // The labels are the ones a spec-derived integration actually produces: an | ||
| 99 | // OpenAPI spec whose security schemes carry no human name yields ordinal | ||
| 100 | // method labels, which is the "Method 1" tab. | ||
| 101 | // --------------------------------------------------------------------------- | ||
| 102 | |||
| 103 | export interface DemoPlacement { | ||
| 104 | readonly carrier: "header" | "query" | "env"; | ||
| 105 | readonly name: string; | ||
| 106 | readonly prefix: string; | ||
| 107 | } | ||
| 108 | |||
| 109 | export interface DemoAuthMethod { | ||
| 110 | readonly id: string; | ||
| 111 | readonly label: string; | ||
| 112 | readonly kind: "apikey" | "oauth" | "none"; | ||
| 113 | readonly placements: readonly DemoPlacement[]; | ||
| 114 | } | ||
| 115 | |||
| 116 | export const posthogAuthMethods: readonly DemoAuthMethod[] = [ | ||
| 117 | { | ||
| 118 | id: "method-1", | ||
| 119 | label: "Method 1", | ||
| 120 | kind: "apikey", | ||
| 121 | placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | ||
| 122 | }, | ||
| 123 | { | ||
| 124 | id: "method-2", | ||
| 125 | label: "Method 2", | ||
| 126 | kind: "apikey", | ||
| 127 | placements: [{ carrier: "query", name: "personal_api_key", prefix: "" }], | ||
| 128 | }, | ||
| 129 | { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] }, | ||
| 130 | ]; | ||
| 131 | |||
| 132 | export const gmailAuthMethods: readonly DemoAuthMethod[] = [ | ||
| 133 | { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] }, | ||
| 134 | ]; | ||
| 135 | |||
| 136 | // --------------------------------------------------------------------------- | ||
| 137 | // integrations.sh — the live public catalog | ||
| 138 | // --------------------------------------------------------------------------- | ||
| 139 | |||
| 140 | export const INTEGRATIONS_SH_ORIGIN = "https://integrations.sh"; | ||
| 141 | |||
| 142 | export const CONNECTABLE_KINDS = ["mcp", "openapi", "graphql"] as const; | ||
| 143 | export type CatalogKind = (typeof CONNECTABLE_KINDS)[number]; | ||
| 144 | |||
| 145 | export interface CatalogEntry { | ||
| 146 | readonly domain: string; | ||
| 147 | readonly description: string; | ||
| 148 | readonly kinds: readonly CatalogKind[]; | ||
| 149 | } | ||
| 150 | |||
| 151 | export const catalogLogoUrl = (domain: string, size: number): string => | ||
| 152 | `${INTEGRATIONS_SH_ORIGIN}/logo/${domain}?sz=${size * 2}`; | ||
| 153 | |||
| 154 | const isConnectableKind = (kind: string): kind is CatalogKind => | ||
| 155 | (CONNECTABLE_KINDS as readonly string[]).includes(kind); | ||
| 156 | |||
| 157 | /** `/api/search` — the same endpoint the console's connect dialog calls, and | ||
| 158 | * the same 250ms-debounced, ≥2-character contract. */ | ||
| 159 | export const searchCatalog = async ( | ||
| 160 | query: string, | ||
| 161 | limit = 10, | ||
| 162 | ): Promise<readonly CatalogEntry[]> => { | ||
| 163 | const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN); | ||
| 164 | url.searchParams.set("q", query); | ||
| 165 | url.searchParams.set("limit", String(limit)); | ||
| 166 | const response = await fetch(url); | ||
| 167 | if (!response.ok) return []; | ||
| 168 | const payload = (await response.json()) as { | ||
| 169 | readonly results?: readonly { | ||
| 170 | readonly domain: string; | ||
| 171 | readonly description: string; | ||
| 172 | readonly kinds: readonly string[]; | ||
| 173 | }[]; | ||
| 174 | }; | ||
| 175 | return (payload.results ?? []) | ||
| 176 | .map((entry) => ({ | ||
| 177 | domain: entry.domain, | ||
| 178 | description: entry.description, | ||
| 179 | kinds: entry.kinds.filter(isConnectableKind), | ||
| 180 | })) | ||
| 181 | .filter((entry) => entry.kinds.length > 0); | ||
| 182 | }; | ||
| 183 | |||
| 184 | export interface CatalogDomain { | ||
| 185 | readonly domain: string; | ||
| 186 | readonly icon: string; | ||
| 187 | readonly description: string; | ||
| 188 | readonly formats: Readonly<Record<string, number>>; | ||
| 189 | readonly popularity: number; | ||
| 190 | } | ||
| 191 | |||
| 192 | /** `/api/domains.json` — the whole popularity-sorted registry (~3.4k domains). | ||
| 193 | * The console never calls this; it is here because it is the list the product | ||
| 194 | * is missing, and the rework will want it. */ | ||
| 195 | export const fetchCatalogDomains = async (): Promise<readonly CatalogDomain[]> => { | ||
| 196 | const response = await fetch(`${INTEGRATIONS_SH_ORIGIN}/api/domains.json`); | ||
| 197 | if (!response.ok) return []; | ||
| 198 | const payload = (await response.json()) as { readonly data?: readonly CatalogDomain[] }; | ||
| 199 | return payload.data ?? []; | ||
| 200 | }; | ||
| 201 | |||
| 202 | export interface SurfaceCredential { | ||
| 203 | readonly id: string; | ||
| 204 | readonly type: string; | ||
| 205 | readonly label: string; | ||
| 206 | readonly generateUrl?: string; | ||
| 207 | readonly setup?: string; | ||
| 208 | } | ||
| 209 | |||
| 210 | /** `/api/<domain>/surface` — the per-domain document. The console reads only | ||
| 211 | * `surfaces[].url` out of this; the `credentials` block (which key, where to | ||
| 212 | * mint it, how) is fetched and thrown away. */ | ||
| 213 | export const fetchSurfaceCredentials = async ( | ||
| 214 | domain: string, | ||
| 215 | ): Promise<readonly SurfaceCredential[]> => { | ||
| 216 | const response = await fetch( | ||
| 217 | `${INTEGRATIONS_SH_ORIGIN}/api/${encodeURIComponent(domain)}/surface`, | ||
| 218 | ); | ||
| 219 | if (!response.ok) return []; | ||
| 220 | const payload = (await response.json()) as { | ||
| 221 | readonly credentials?: Readonly< | ||
| 222 | Record< | ||
| 223 | string, | ||
| 224 | { | ||
| 225 | readonly type?: string; | ||
| 226 | readonly label?: string; | ||
| 227 | readonly generateUrl?: string; | ||
| 228 | readonly setup?: string; | ||
| 229 | } | ||
| 230 | > | ||
| 231 | >; | ||
| 232 | }; | ||
| 233 | return Object.entries(payload.credentials ?? {}).map(([id, value]) => ({ | ||
| 234 | id, | ||
| 235 | type: value.type ?? "unknown", | ||
| 236 | label: value.label ?? id, | ||
| 237 | ...(value.generateUrl ? { generateUrl: value.generateUrl } : {}), | ||
| 238 | ...(value.setup ? { setup: value.setup } : {}), | ||
| 239 | })); | ||
| 240 | }; | ||
| @@ -0,0 +1,111 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // The flow, as one ordered list of screens. | ||
| 3 | // | ||
| 4 | // Each step carries the first-run reaction recorded against that exact screen, | ||
| 5 | // so a redesign can be checked against the complaint it is meant to answer | ||
| 6 | // rather than against a general sense that the old screen was bad. | ||
| 7 | // --------------------------------------------------------------------------- | ||
| 8 | |||
| 9 | export type StepId = | ||
| 10 | | "integrations-empty" | ||
| 11 | | "connect-dialog" | ||
| 12 | | "add-openapi" | ||
| 13 | | "detail-accounts" | ||
| 14 | | "add-account-credential" | ||
| 15 | | "add-account-place" | ||
| 16 | | "oauth-stuck" | ||
| 17 | | "integrations-populated"; | ||
| 18 | |||
| 19 | export interface FlowStep { | ||
| 20 | readonly id: StepId; | ||
| 21 | readonly label: string; | ||
| 22 | /** Where this screen lives in the real console. */ | ||
| 23 | readonly route: string; | ||
| 24 | /** The component this reproduction is taken from. */ | ||
| 25 | readonly source: string; | ||
| 26 | /** Verbatim first-run reactions to this screen. */ | ||
| 27 | readonly reactions: readonly string[]; | ||
| 28 | } | ||
| 29 | |||
| 30 | export const flowSteps: readonly FlowStep[] = [ | ||
| 31 | { | ||
| 32 | id: "integrations-empty", | ||
| 33 | label: "Integrations (empty)", | ||
| 34 | route: "/", | ||
| 35 | source: "packages/react/src/pages/integrations.tsx", | ||
| 36 | reactions: [ | ||
| 37 | "Adding to clis was easy. Not clear what to do next.", | ||
| 38 | "Next is this, not a great screen to find integrations from. No text search? I just wanted to add email", | ||
| 39 | ], | ||
| 40 | }, | ||
| 41 | { | ||
| 42 | id: "connect-dialog", | ||
| 43 | label: "Connect dialog", | ||
| 44 | route: "/ (dialog)", | ||
| 45 | source: "packages/react/src/pages/integrations.tsx › ConnectDialog", | ||
| 46 | reactions: [ | ||
| 47 | "I'd love a proper like picker grid and search", | ||
| 48 | "I want to add multiple to set up for experimentation. I clicked the first one I want (posthog) and it brought me here", | ||
| 49 | "Annoying because now I have to sit and wait for it to do whatever and then add, and I could be spending this time finding other integrations I want", | ||
| 50 | ], | ||
| 51 | }, | ||
| 52 | { | ||
| 53 | id: "add-openapi", | ||
| 54 | label: "Add integration form", | ||
| 55 | route: "/integrations/add/openapi", | ||
| 56 | source: "packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx", | ||
| 57 | reactions: [ | ||
| 58 | "This view is a bit of a mess. There's no clear hierarchy on what exactly I'm supposed to be doing, what matters and doesn't, and what button I should hit. There's the big add integration at the bottom and the weird treatment around it. Just very unclear what the step is", | ||
| 59 | "Hit “add” and now another loading screen, well spinning wheel button", | ||
| 60 | ], | ||
| 61 | }, | ||
| 62 | { | ||
| 63 | id: "detail-accounts", | ||
| 64 | label: "Integration detail · Accounts", | ||
| 65 | route: "/integrations/posthog?tab=accounts", | ||
| 66 | source: "packages/react/src/pages/integration-detail.tsx", | ||
| 67 | reactions: [ | ||
| 68 | "wtf is this?? I just added it why do I have to add it again??", | ||
| 69 | "The hierarchy of the integrations and connections is not something I should have to understand or care about as I initially setup", | ||
| 70 | "I increasingly hate this view. The more that I look at it, there are just so many things demanding attention and none of them are the thing I actually want to do", | ||
| 71 | ], | ||
| 72 | }, | ||
| 73 | { | ||
| 74 | id: "add-account-credential", | ||
| 75 | label: "Add connection · credential", | ||
| 76 | route: "/integrations/posthog?tab=accounts&addAccount=1", | ||
| 77 | source: "packages/react/src/components/add-account-modal.tsx", | ||
| 78 | reactions: [ | ||
| 79 | "Where the fk do I paste the key?? The Method 1 thing is locked", | ||
| 80 | "I'm not scared of doing anything because I already copied the API key and it's on my clipboard but I can't paste it yet. I just have to not use my computer because I don't have clipboard history management and never will", | ||
| 81 | "I'm even scared to send a screenshot because I'm going to lose the key. I'm just going to have to recreate it. I think I'll throw it in a note.", | ||
| 82 | "Okay so what type of key do I need? Obviously doing this for every integration will be annoying but I'm sure you can get some agent to go do it.", | ||
| 83 | ], | ||
| 84 | }, | ||
| 85 | { | ||
| 86 | id: "add-account-place", | ||
| 87 | label: "Add connection · placement + owner", | ||
| 88 | route: "/integrations/posthog?tab=accounts&addAccount=1", | ||
| 89 | source: "packages/react/src/components/add-account-modal.tsx", | ||
| 90 | reactions: ["How many times does the word “personal” appear on this page? lol"], | ||
| 91 | }, | ||
| 92 | { | ||
| 93 | id: "oauth-stuck", | ||
| 94 | label: "OAuth · stuck connecting", | ||
| 95 | route: "/integrations/gmail?tab=accounts&addAccount=1", | ||
| 96 | source: "packages/react/src/plugins/oauth-sign-in.tsx", | ||
| 97 | reactions: [ | ||
| 98 | "Closed the broken auth page and now app is stuck in “connecting...”", | ||
| 99 | "On business Gmail, allowed me to click an “Advanced options” (“I trust the developer”) thing, but normal personal accounts, no", | ||
| 100 | ], | ||
| 101 | }, | ||
| 102 | { | ||
| 103 | id: "integrations-populated", | ||
| 104 | label: "Integrations (populated)", | ||
| 105 | route: "/", | ||
| 106 | source: "packages/react/src/pages/integrations.tsx", | ||
| 107 | reactions: ["This is filling me with rage for some reason"], | ||
| 108 | }, | ||
| 109 | ]; | ||
| 110 | |||
| 111 | export const stepIndex = (id: StepId): number => flowSteps.findIndex((step) => step.id === id); | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | import { StrictMode } from "react"; | ||
| 2 | import { createRoot } from "react-dom/client"; | ||
| 3 | import { App } from "./app"; | ||
| 4 | import "./styles.css"; | ||
| 5 | |||
| 6 | const root = document.getElementById("root"); | ||
| 7 | if (root) | ||
| 8 | createRoot(root).render( | ||
| 9 | <StrictMode> | ||
| 10 | <App /> | ||
| 11 | </StrictMode>, | ||
| 12 | ); | ||
| @@ -0,0 +1,304 @@ | |||
| 1 | // The reworked flow, wired end to end: browse → add (in place) → the added | ||
| 2 | // integration → authenticate, plus the custom-URL door that bypasses the | ||
| 3 | // registry entirely. Adding never blocks and never navigates; the only thing | ||
| 4 | // that moves you off the picker is choosing to look at something. | ||
| 5 | // | ||
| 6 | // Every screen is a hash route, so any of them can be linked to directly: | ||
| 7 | // #reworked the picker | ||
| 8 | // #reworked/installed what you've added | ||
| 9 | // #reworked/custom the custom-URL dialog | ||
| 10 | // #reworked/<domain> one integration | ||
| 11 | // #reworked/<domain>/auth …with its authenticate dialog open | ||
| 12 | |||
| 13 | import { useCallback, useEffect, useState } from "react"; | ||
| 14 | import { Button } from "@executor-js/react/components/button"; | ||
| 15 | import { cn } from "@executor-js/react/lib/utils"; | ||
| 16 | import { Shell } from "./shell"; | ||
| 17 | import { BrowsePage } from "./screens/next/browse"; | ||
| 18 | import { | ||
| 19 | InstalledList, | ||
| 20 | NextIntegrationDetail, | ||
| 21 | type DemoAccount, | ||
| 22 | } from "./screens/next/integration-detail"; | ||
| 23 | import { AuthenticateDialog } from "./screens/next/authenticate"; | ||
| 24 | import { AddCustomDialog } from "./screens/next/custom"; | ||
| 25 | import { fromCatalogItem, type AddedIntegration, type CatalogItem } from "./catalog"; | ||
| 26 | import type { DemoIntegration } from "./fixtures"; | ||
| 27 | |||
| 28 | interface Added { | ||
| 29 | readonly item: AddedIntegration; | ||
| 30 | readonly accounts: readonly DemoAccount[]; | ||
| 31 | } | ||
| 32 | |||
| 33 | // --------------------------------------------------------------------------- | ||
| 34 | // Routing | ||
| 35 | // --------------------------------------------------------------------------- | ||
| 36 | |||
| 37 | export type NextRoute = | ||
| 38 | | { readonly kind: "browse" } | ||
| 39 | | { readonly kind: "installed" } | ||
| 40 | | { readonly kind: "custom" } | ||
| 41 | | { readonly kind: "detail"; readonly domain: string } | ||
| 42 | | { readonly kind: "auth"; readonly domain: string }; | ||
| 43 | |||
| 44 | const parseRoute = (hash: string): NextRoute => { | ||
| 45 | // `#reworked/...` is canonical; `#next/...` is kept working because it was | ||
| 46 | // the first spelling and may already be pasted somewhere. | ||
| 47 | const path = hash.replace(/^#/, "").replace(/^(reworked|next)\/?/, ""); | ||
| 48 | const segments = path.split("/").filter((segment) => segment.length > 0); | ||
| 49 | if (segments.length === 0) return { kind: "browse" }; | ||
| 50 | const [first, second] = segments; | ||
| 51 | if (first === "installed") return { kind: "installed" }; | ||
| 52 | if (first === "custom") return { kind: "custom" }; | ||
| 53 | if (first && second === "auth") return { kind: "auth", domain: first }; | ||
| 54 | if (first) return { kind: "detail", domain: first }; | ||
| 55 | return { kind: "browse" }; | ||
| 56 | }; | ||
| 57 | |||
| 58 | const routeToHash = (route: NextRoute): string => { | ||
| 59 | if (route.kind === "browse") return "#reworked"; | ||
| 60 | if (route.kind === "installed") return "#reworked/installed"; | ||
| 61 | if (route.kind === "custom") return "#reworked/custom"; | ||
| 62 | if (route.kind === "auth") return `#reworked/${route.domain}/auth`; | ||
| 63 | return `#reworked/${route.domain}`; | ||
| 64 | }; | ||
| 65 | |||
| 66 | // --------------------------------------------------------------------------- | ||
| 67 | |||
| 68 | const toolsFor = (accounts: readonly DemoAccount[]): number => | ||
| 69 | accounts.some((account) => account.status === "connected") ? 24 : 0; | ||
| 70 | |||
| 71 | /** What the console sidebar lists: the same added integrations, in the shape | ||
| 72 | * the shell already renders. */ | ||
| 73 | const asSidebarIntegration = (added: Added): DemoIntegration => ({ | ||
| 74 | slug: added.item.domain, | ||
| 75 | name: added.item.name, | ||
| 76 | kind: "openapi", | ||
| 77 | icon: added.item.icon, | ||
| 78 | toolCount: toolsFor(added.accounts), | ||
| 79 | }); | ||
| 80 | |||
| 81 | export function NextFlow() { | ||
| 82 | const [added, setAdded] = useState<readonly Added[]>([]); | ||
| 83 | const [route, setRoute] = useState<NextRoute>(() => parseRoute(globalThis.location?.hash ?? "")); | ||
| 84 | |||
| 85 | useEffect(() => { | ||
| 86 | const onHashChange = () => setRoute(parseRoute(globalThis.location?.hash ?? "")); | ||
| 87 | globalThis.addEventListener("hashchange", onHashChange); | ||
| 88 | return () => globalThis.removeEventListener("hashchange", onHashChange); | ||
| 89 | }, []); | ||
| 90 | |||
| 91 | const go = useCallback((next: NextRoute) => { | ||
| 92 | setRoute(next); | ||
| 93 | if (globalThis.location) globalThis.location.hash = routeToHash(next); | ||
| 94 | }, []); | ||
| 95 | |||
| 96 | const addIntegration = useCallback((item: AddedIntegration) => { | ||
| 97 | setAdded((previous) => | ||
| 98 | previous.some((entry) => entry.item.domain === item.domain) | ||
| 99 | ? previous | ||
| 100 | : [ | ||
| 101 | ...previous, | ||
| 102 | // An add always leaves exactly one account behind, already named | ||
| 103 | // and in the one state that matters. | ||
| 104 | { item, accounts: [{ label: "default", status: "needs-auth" as const }] }, | ||
| 105 | ], | ||
| 106 | ); | ||
| 107 | }, []); | ||
| 108 | |||
| 109 | const addFromCatalog = useCallback( | ||
| 110 | (item: CatalogItem) => addIntegration(fromCatalogItem(item)), | ||
| 111 | [addIntegration], | ||
| 112 | ); | ||
| 113 | |||
| 114 | const markConnected = useCallback((domain: string, accountLabel: string, identity: string) => { | ||
| 115 | setAdded((previous) => | ||
| 116 | previous.map((entry) => | ||
| 117 | entry.item.domain === domain | ||
| 118 | ? { | ||
| 119 | ...entry, | ||
| 120 | accounts: entry.accounts.map((account) => | ||
| 121 | account.label === accountLabel | ||
| 122 | ? { ...account, status: "connected" as const, identity } | ||
| 123 | : account, | ||
| 124 | ), | ||
| 125 | } | ||
| 126 | : entry, | ||
| 127 | ), | ||
| 128 | ); | ||
| 129 | }, []); | ||
| 130 | |||
| 131 | const activeDomain = route.kind === "detail" || route.kind === "auth" ? route.domain : null; | ||
| 132 | const open = added.find((entry) => entry.item.domain === activeDomain) ?? null; | ||
| 133 | const pendingCount = added.filter((entry) => | ||
| 134 | entry.accounts.some((account) => account.status === "needs-auth"), | ||
| 135 | ).length; | ||
| 136 | |||
| 137 | // A deep link can name a domain that has not been added in this session. | ||
| 138 | // Say so rather than rendering an empty detail screen. | ||
| 139 | const missing = activeDomain !== null && open === null; | ||
| 140 | |||
| 141 | const showingInstalled = route.kind === "installed"; | ||
| 142 | const showingBrowse = route.kind === "browse" || route.kind === "custom"; | ||
| 143 | |||
| 144 | return ( | ||
| 145 | <Shell integrations={added.map(asSidebarIntegration)}> | ||
| 146 | <div className="min-h-0 flex-1 overflow-y-auto"> | ||
| 147 | <div className="mx-auto max-w-4xl px-8 py-10"> | ||
| 148 | {open && !missing ? ( | ||
| 149 | <NextIntegrationDetail | ||
| 150 | item={open.item} | ||
| 151 | accounts={open.accounts} | ||
| 152 | toolCount={toolsFor(open.accounts)} | ||
| 153 | onBack={() => go({ kind: "installed" })} | ||
| 154 | onAuthenticate={() => go({ kind: "auth", domain: open.item.domain })} | ||
| 155 | onAddAccount={() => | ||
| 156 | setAdded((previous) => | ||
| 157 | previous.map((entry) => | ||
| 158 | entry.item.domain === open.item.domain | ||
| 159 | ? { | ||
| 160 | ...entry, | ||
| 161 | accounts: [ | ||
| 162 | ...entry.accounts, | ||
| 163 | { | ||
| 164 | label: `account ${entry.accounts.length + 1}`, | ||
| 165 | status: "needs-auth" as const, | ||
| 166 | }, | ||
| 167 | ], | ||
| 168 | } | ||
| 169 | : entry, | ||
| 170 | ), | ||
| 171 | ) | ||
| 172 | } | ||
| 173 | onRemove={() => { | ||
| 174 | setAdded((previous) => | ||
| 175 | previous.filter((entry) => entry.item.domain !== open.item.domain), | ||
| 176 | ); | ||
| 177 | go({ kind: "installed" }); | ||
| 178 | }} | ||
| 179 | /> | ||
| 180 | ) : missing ? ( | ||
| 181 | <div className="rounded-lg border border-dashed border-border py-16 text-center"> | ||
| 182 | <p className="text-sm font-medium text-foreground"> | ||
| 183 | {activeDomain} isn't added in this session | ||
| 184 | </p> | ||
| 185 | <p className="mt-1 text-sm text-muted-foreground"> | ||
| 186 | This prototype keeps nothing between reloads. | ||
| 187 | </p> | ||
| 188 | <Button | ||
| 189 | type="button" | ||
| 190 | size="sm" | ||
| 191 | className="mt-4" | ||
| 192 | onClick={() => go({ kind: "browse" })} | ||
| 193 | > | ||
| 194 | Browse integrations | ||
| 195 | </Button> | ||
| 196 | </div> | ||
| 197 | ) : ( | ||
| 198 | <> | ||
| 199 | <div className="mb-6 flex items-center justify-between gap-4"> | ||
| 200 | <div className="flex items-center gap-0.5 rounded-lg bg-muted/60 p-0.5"> | ||
| 201 | {/* oxlint-disable-next-line react/forbid-elements */} | ||
| 202 | <button | ||
| 203 | type="button" | ||
| 204 | onClick={() => go({ kind: "browse" })} | ||
| 205 | className={cn( | ||
| 206 | "rounded-md px-3 py-1.5 text-sm font-medium transition-colors", | ||
| 207 | showingBrowse | ||
| 208 | ? "bg-background text-foreground shadow-xs" | ||
| 209 | : "text-muted-foreground hover:text-foreground", | ||
| 210 | )} | ||
| 211 | > | ||
| 212 | Browse | ||
| 213 | </button> | ||
| 214 | {/* oxlint-disable-next-line react/forbid-elements */} | ||
| 215 | <button | ||
| 216 | type="button" | ||
| 217 | onClick={() => go({ kind: "installed" })} | ||
| 218 | className={cn( | ||
| 219 | "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors", | ||
| 220 | showingInstalled | ||
| 221 | ? "bg-background text-foreground shadow-xs" | ||
| 222 | : "text-muted-foreground hover:text-foreground", | ||
| 223 | )} | ||
| 224 | > | ||
| 225 | Installed | ||
| 226 | {added.length > 0 ? ( | ||
| 227 | <span className="font-mono text-xs text-muted-foreground"> | ||
| 228 | {added.length} | ||
| 229 | </span> | ||
| 230 | ) : null} | ||
| 231 | </button> | ||
| 232 | </div> | ||
| 233 | {pendingCount > 0 ? ( | ||
| 234 | <span className="shrink-0 text-xs font-medium text-amber-500"> | ||
| 235 | {pendingCount} need{pendingCount === 1 ? "s" : ""} auth | ||
| 236 | </span> | ||
| 237 | ) : null} | ||
| 238 | </div> | ||
| 239 | |||
| 240 | {showingInstalled ? ( | ||
| 241 | <InstalledList | ||
| 242 | items={added} | ||
| 243 | onOpen={(domain) => go({ kind: "detail", domain })} | ||
| 244 | onBrowse={() => go({ kind: "browse" })} | ||
| 245 | /> | ||
| 246 | ) : ( | ||
| 247 | <BrowsePage | ||
| 248 | addedDomains={added.map((entry) => entry.item.domain)} | ||
| 249 | onAdd={addFromCatalog} | ||
| 250 | onOpen={(item) => { | ||
| 251 | addFromCatalog(item); | ||
| 252 | go({ kind: "detail", domain: item.domain }); | ||
| 253 | }} | ||
| 254 | onAddCustom={() => go({ kind: "custom" })} | ||
| 255 | /> | ||
| 256 | )} | ||
| 257 | </> | ||
| 258 | )} | ||
| 259 | </div> | ||
| 260 | </div> | ||
| 261 | |||
| 262 | {route.kind === "custom" ? ( | ||
| 263 | <AddCustomDialog | ||
| 264 | open | ||
| 265 | onOpenChange={(next) => { | ||
| 266 | if (!next) go({ kind: "browse" }); | ||
| 267 | }} | ||
| 268 | onAdd={(integration) => { | ||
| 269 | addIntegration(integration); | ||
| 270 | go({ kind: "detail", domain: integration.domain }); | ||
| 271 | }} | ||
| 272 | /> | ||
| 273 | ) : null} | ||
| 274 | |||
| 275 | {route.kind === "auth" && open ? ( | ||
| 276 | <AuthenticateDialog | ||
| 277 | // Keyed per target so the dialog owns its state and a close throws it | ||
| 278 | // away, rather than carrying a half-finished attempt to the next one. | ||
| 279 | key={open.item.domain} | ||
| 280 | open | ||
| 281 | onOpenChange={(next) => { | ||
| 282 | if (!next) go({ kind: "detail", domain: open.item.domain }); | ||
| 283 | }} | ||
| 284 | integrationName={open.item.name} | ||
| 285 | domain={open.item.domain} | ||
| 286 | accountLabel={ | ||
| 287 | open.accounts.find((account) => account.status === "needs-auth")?.label ?? "default" | ||
| 288 | } | ||
| 289 | onAuthenticated={() => { | ||
| 290 | const account = | ||
| 291 | open.accounts.find((candidate) => candidate.status === "needs-auth")?.label ?? | ||
| 292 | "default"; | ||
| 293 | markConnected( | ||
| 294 | open.item.domain, | ||
| 295 | account, | ||
| 296 | `rhys@${open.item.domain.split(".")[0] ?? open.item.domain}`, | ||
| 297 | ); | ||
| 298 | go({ kind: "detail", domain: open.item.domain }); | ||
| 299 | }} | ||
| 300 | /> | ||
| 301 | ) : null} | ||
| 302 | </Shell> | ||
| 303 | ); | ||
| 304 | } | ||
| @@ -0,0 +1,318 @@ | |||
| 1 | // Reproduction of packages/react/src/components/add-account-modal.tsx. | ||
| 2 | // | ||
| 3 | // The credential path is a 2-step wizard behind a horizontally scrolling strip | ||
| 4 | // of auth-method tabs. Step 1 is the key plus an optional read-only probe; | ||
| 5 | // step 2 is the display name and the "Connection saved to" owner picker. The | ||
| 6 | // OAuth path is not a wizard and shows an app picker instead. | ||
| 7 | // | ||
| 8 | // The word "Personal" is reproduced at every site it occurs on the real screen: | ||
| 9 | // the derived connection name, the callable-name hint, the owner picker's label | ||
| 10 | // and its selected option. | ||
| 11 | |||
| 12 | import { useState } from "react"; | ||
| 13 | import { EyeIcon, EyeOffIcon, PlusIcon } from "lucide-react"; | ||
| 14 | import { Button } from "@executor-js/react/components/button"; | ||
| 15 | import { Input } from "@executor-js/react/components/input"; | ||
| 16 | import { Label } from "@executor-js/react/components/label"; | ||
| 17 | import { | ||
| 18 | Dialog, | ||
| 19 | DialogContent, | ||
| 20 | DialogDescription, | ||
| 21 | DialogFooter, | ||
| 22 | DialogHeader, | ||
| 23 | DialogTitle, | ||
| 24 | } from "@executor-js/react/components/dialog"; | ||
| 25 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; | ||
| 26 | import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; | ||
| 27 | import { cn } from "@executor-js/react/lib/utils"; | ||
| 28 | import type { DemoAuthMethod } from "../fixtures"; | ||
| 29 | |||
| 30 | function StepHeader(props: { readonly label: string; readonly hint?: string }) { | ||
| 31 | return ( | ||
| 32 | <div className="flex flex-col gap-1"> | ||
| 33 | <Label className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> | ||
| 34 | {props.label} | ||
| 35 | </Label> | ||
| 36 | {props.hint ? <span className="text-xs text-muted-foreground/70">{props.hint}</span> : null} | ||
| 37 | </div> | ||
| 38 | ); | ||
| 39 | } | ||
| 40 | |||
| 41 | function PlacementLine(props: { readonly placement: DemoAuthMethod["placements"][number] }) { | ||
| 42 | const { placement } = props; | ||
| 43 | const lead = | ||
| 44 | placement.carrier === "header" | ||
| 45 | ? `${placement.name || "Authorization"}: ` | ||
| 46 | : placement.carrier === "env" | ||
| 47 | ? `${placement.name || "TOKEN"}=` | ||
| 48 | : `?${placement.name || "api_key"}=`; | ||
| 49 | return ( | ||
| 50 | <span className="wrap-anywhere whitespace-pre-wrap font-mono text-xs text-muted-foreground"> | ||
| 51 | {lead} | ||
| 52 | {placement.prefix ? ( | ||
| 53 | <span className="text-muted-foreground/60">{placement.prefix}</span> | ||
| 54 | ) : null} | ||
| 55 | <span className="tracking-widest text-primary">••••••</span> | ||
| 56 | </span> | ||
| 57 | ); | ||
| 58 | } | ||
| 59 | |||
| 60 | export function AddAccountModal(props: { | ||
| 61 | readonly open: boolean; | ||
| 62 | readonly onOpenChange: (open: boolean) => void; | ||
| 63 | readonly integrationName: string; | ||
| 64 | readonly methods: readonly DemoAuthMethod[]; | ||
| 65 | /** Start on the wizard's second step, which is the screen the owner picker | ||
| 66 | * and its repeated "Personal" live on. */ | ||
| 67 | readonly initialStep?: "validate" | "place"; | ||
| 68 | /** Reproduce the post-OAuth stuck state: the popup was closed, and the | ||
| 69 | * button never leaves "Connecting…". */ | ||
| 70 | readonly stuckConnecting?: boolean; | ||
| 71 | readonly onAdded: () => void; | ||
| 72 | }) { | ||
| 73 | const [methodId, setMethodId] = useState(props.methods[0]?.id ?? ""); | ||
| 74 | const [wizardStep, setWizardStep] = useState<"validate" | "place">( | ||
| 75 | props.initialStep ?? "validate", | ||
| 76 | ); | ||
| 77 | const [secret, setSecret] = useState(""); | ||
| 78 | const [revealed, setRevealed] = useState(false); | ||
| 79 | const [label, setLabel] = useState(""); | ||
| 80 | const [owner, setOwner] = useState<"user" | "org">("user"); | ||
| 81 | |||
| 82 | const method = props.methods.find((candidate) => candidate.id === methodId) ?? props.methods[0]; | ||
| 83 | const isOAuth = method?.kind === "oauth"; | ||
| 84 | const wizardActive = !isOAuth; | ||
| 85 | const showValidateStep = !wizardActive || wizardStep === "validate"; | ||
| 86 | const showPlaceStep = !wizardActive || wizardStep === "place"; | ||
| 87 | |||
| 88 | const ownerWord = owner === "user" ? "Personal" : "Workspace"; | ||
| 89 | const derivedName = `${ownerWord} ${props.integrationName}`; | ||
| 90 | const callableName = (label.trim() || derivedName).toLowerCase().replace(/[^a-z0-9]+/g, "_"); | ||
| 91 | |||
| 92 | const singlePlacement = method?.placements[0]; | ||
| 93 | const affix = | ||
| 94 | singlePlacement && singlePlacement.carrier === "header" | ||
| 95 | ? `${singlePlacement.name}: ${singlePlacement.prefix}` | ||
| 96 | : null; | ||
| 97 | |||
| 98 | return ( | ||
| 99 | <Dialog open={props.open} onOpenChange={props.onOpenChange} modal={false}> | ||
| 100 | <DialogContent forceOverlay className="max-h-[85vh] overflow-y-auto sm:max-w-xl"> | ||
| 101 | <DialogHeader> | ||
| 102 | <DialogTitle> | ||
| 103 | Add connection · {props.integrationName} | ||
| 104 | {wizardActive ? ( | ||
| 105 | <span className="ml-2 font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> | ||
| 106 | Step {wizardStep === "validate" ? 1 : 2} of 2 | ||
| 107 | </span> | ||
| 108 | ) : null} | ||
| 109 | </DialogTitle> | ||
| 110 | <DialogDescription> | ||
| 111 | A connection is a saved way to use this integration, owned by you or the workspace. | ||
| 112 | </DialogDescription> | ||
| 113 | </DialogHeader> | ||
| 114 | |||
| 115 | <div className="flex w-full min-w-0 flex-col gap-5"> | ||
| 116 | {showValidateStep && ( | ||
| 117 | <Tabs | ||
| 118 | value={methodId} | ||
| 119 | onValueChange={(next: string) => { | ||
| 120 | setMethodId(next); | ||
| 121 | setWizardStep("validate"); | ||
| 122 | }} | ||
| 123 | className="w-full min-w-0 max-w-full gap-0" | ||
| 124 | > | ||
| 125 | <TabsList className="flex h-10 w-full min-w-0 max-w-full justify-start overflow-x-auto overflow-y-hidden rounded-b-none rounded-t-md border border-b-0 border-border/60 bg-muted/30 p-1 [scrollbar-width:thin]"> | ||
| 126 | <div className="flex w-max shrink-0 items-stretch gap-1"> | ||
| 127 | {props.methods.map((candidate) => ( | ||
| 128 | <div | ||
| 129 | key={candidate.id} | ||
| 130 | className="group/method-tab relative flex h-8 shrink-0 items-stretch" | ||
| 131 | > | ||
| 132 | <TabsTrigger | ||
| 133 | value={candidate.id} | ||
| 134 | className="h-full max-w-64 shrink-0 justify-start px-3 text-sm font-medium" | ||
| 135 | > | ||
| 136 | <span className="truncate">{candidate.label}</span> | ||
| 137 | </TabsTrigger> | ||
| 138 | </div> | ||
| 139 | ))} | ||
| 140 | <Button | ||
| 141 | type="button" | ||
| 142 | variant="ghost" | ||
| 143 | size="icon-sm" | ||
| 144 | aria-label="Add authentication method" | ||
| 145 | className="h-8 shrink-0 rounded-md border border-transparent bg-transparent px-3 text-foreground/60 hover:bg-background/60" | ||
| 146 | > | ||
| 147 | <PlusIcon className="size-4" /> | ||
| 148 | </Button> | ||
| 149 | </div> | ||
| 150 | </TabsList> | ||
| 151 | |||
| 152 | <TabsContent | ||
| 153 | value={methodId} | ||
| 154 | className={cn( | ||
| 155 | "mt-0 min-w-0 space-y-5", | ||
| 156 | "rounded-b-md rounded-t-none border border-border/60 bg-muted/15 p-4", | ||
| 157 | )} | ||
| 158 | > | ||
| 159 | {method && !isOAuth && method.placements.length > 0 && !affix ? ( | ||
| 160 | <div className="flex flex-wrap gap-x-3.5 gap-y-1"> | ||
| 161 | {method.placements.map((placement, index) => ( | ||
| 162 | <PlacementLine key={index} placement={placement} /> | ||
| 163 | ))} | ||
| 164 | </div> | ||
| 165 | ) : null} | ||
| 166 | |||
| 167 | <div className="space-y-2"> | ||
| 168 | <StepHeader label={isOAuth ? "OAuth app" : "Credential"} /> | ||
| 169 | |||
| 170 | {isOAuth ? ( | ||
| 171 | <div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-3"> | ||
| 172 | <p className="text-sm font-medium">No app for {props.integrationName} yet</p> | ||
| 173 | <p className="text-xs text-muted-foreground"> | ||
| 174 | None of your registered apps target this integration's OAuth endpoint. | ||
| 175 | Register one to connect. | ||
| 176 | </p> | ||
| 177 | <div className="flex flex-wrap items-center gap-2 pt-1"> | ||
| 178 | <Button type="button" size="sm"> | ||
| 179 | Register app | ||
| 180 | </Button> | ||
| 181 | </div> | ||
| 182 | </div> | ||
| 183 | ) : affix ? ( | ||
| 184 | <div className="flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-input bg-transparent font-mono text-sm shadow-xs transition-colors focus-within:border-ring dark:bg-input/30"> | ||
| 185 | <span className="flex min-w-0 max-w-[60%] select-none items-center border-r border-input bg-muted/40 px-3 text-muted-foreground/70"> | ||
| 186 | <span className="overflow-hidden text-ellipsis whitespace-pre"> | ||
| 187 | {affix} | ||
| 188 | </span> | ||
| 189 | </span> | ||
| 190 | {/* oxlint-disable-next-line react/forbid-elements */} | ||
| 191 | <input | ||
| 192 | type={revealed ? "text" : "password"} | ||
| 193 | autoComplete="off" | ||
| 194 | aria-label="API key" | ||
| 195 | value={secret} | ||
| 196 | onChange={(event) => setSecret(event.target.value)} | ||
| 197 | className="min-w-0 flex-1 bg-transparent px-3 outline-none" | ||
| 198 | /> | ||
| 199 | <Button | ||
| 200 | type="button" | ||
| 201 | variant="ghost" | ||
| 202 | size="icon-sm" | ||
| 203 | aria-label="Show key" | ||
| 204 | aria-pressed={revealed} | ||
| 205 | className="shrink-0 text-muted-foreground" | ||
| 206 | onClick={() => setRevealed((value) => !value)} | ||
| 207 | > | ||
| 208 | {revealed ? <EyeOffIcon /> : <EyeIcon />} | ||
| 209 | </Button> | ||
| 210 | </div> | ||
| 211 | ) : ( | ||
| 212 | <div className="flex items-center gap-1"> | ||
| 213 | <Input | ||
| 214 | type={revealed ? "text" : "password"} | ||
| 215 | aria-label="API key" | ||
| 216 | value={secret} | ||
| 217 | onChange={(event) => setSecret((event.target as HTMLInputElement).value)} | ||
| 218 | className="font-mono" | ||
| 219 | /> | ||
| 220 | <Button | ||
| 221 | type="button" | ||
| 222 | variant="ghost" | ||
| 223 | size="icon-sm" | ||
| 224 | aria-label="Show key" | ||
| 225 | aria-pressed={revealed} | ||
| 226 | className="shrink-0 text-muted-foreground" | ||
| 227 | onClick={() => setRevealed((value) => !value)} | ||
| 228 | > | ||
| 229 | {revealed ? <EyeOffIcon /> : <EyeIcon />} | ||
| 230 | </Button> | ||
| 231 | </div> | ||
| 232 | )} | ||
| 233 | </div> | ||
| 234 | |||
| 235 | {!isOAuth && ( | ||
| 236 | <div className="space-y-1.5"> | ||
| 237 | <StepHeader | ||
| 238 | label="Check the key works" | ||
| 239 | hint="runs one read-only call with your key, saved as this integration's health check" | ||
| 240 | /> | ||
| 241 | <div className="space-y-2 rounded-md border border-border/60 bg-background px-3 py-3"> | ||
| 242 | <Label className="text-xs text-muted-foreground">Operation</Label> | ||
| 243 | <NativeSelect size="sm" defaultValue="" aria-label="Health check operation"> | ||
| 244 | <NativeSelectOption value="">Pick an operation…</NativeSelectOption> | ||
| 245 | <NativeSelectOption value="users_me">users_me</NativeSelectOption> | ||
| 246 | <NativeSelectOption value="organizations_list"> | ||
| 247 | organizations_list | ||
| 248 | </NativeSelectOption> | ||
| 249 | <NativeSelectOption value="projects_list">projects_list</NativeSelectOption> | ||
| 250 | </NativeSelect> | ||
| 251 | <Button type="button" variant="outline" size="sm" disabled={!secret}> | ||
| 252 | Check | ||
| 253 | </Button> | ||
| 254 | </div> | ||
| 255 | </div> | ||
| 256 | )} | ||
| 257 | </TabsContent> | ||
| 258 | </Tabs> | ||
| 259 | )} | ||
| 260 | |||
| 261 | {showPlaceStep && ( | ||
| 262 | <> | ||
| 263 | <div className="space-y-2"> | ||
| 264 | <StepHeader label="Display name" hint="how you'll tell accounts apart" /> | ||
| 265 | <Input | ||
| 266 | id="connection-name" | ||
| 267 | placeholder={derivedName} | ||
| 268 | value={label} | ||
| 269 | onChange={(event) => setLabel((event.target as HTMLInputElement).value)} | ||
| 270 | /> | ||
| 271 | <p className="text-xs text-muted-foreground"> | ||
| 272 | This connection will be callable as{" "} | ||
| 273 | <span className="font-mono text-foreground">{callableName}</span>. | ||
| 274 | </p> | ||
| 275 | </div> | ||
| 276 | |||
| 277 | <div className="space-y-2"> | ||
| 278 | <StepHeader label="Connection saved to" /> | ||
| 279 | <NativeSelect | ||
| 280 | value={owner} | ||
| 281 | aria-label="Saved to" | ||
| 282 | onChange={(event) => setOwner(event.target.value === "org" ? "org" : "user")} | ||
| 283 | > | ||
| 284 | <NativeSelectOption value="user">Personal</NativeSelectOption> | ||
| 285 | <NativeSelectOption value="org">Workspace</NativeSelectOption> | ||
| 286 | </NativeSelect> | ||
| 287 | </div> | ||
| 288 | </> | ||
| 289 | )} | ||
| 290 | </div> | ||
| 291 | |||
| 292 | <DialogFooter> | ||
| 293 | <Button type="button" variant="ghost" onClick={() => props.onOpenChange(false)}> | ||
| 294 | {isOAuth ? "Close" : "Cancel"} | ||
| 295 | </Button> | ||
| 296 | {isOAuth ? ( | ||
| 297 | <Button type="button" disabled> | ||
| 298 | {props.stuckConnecting ? "Connecting…" : "Connect with OAuth"} | ||
| 299 | </Button> | ||
| 300 | ) : wizardStep === "validate" ? ( | ||
| 301 | <Button type="button" onClick={() => setWizardStep("place")}> | ||
| 302 | Continue | ||
| 303 | </Button> | ||
| 304 | ) : ( | ||
| 305 | <> | ||
| 306 | <Button type="button" variant="ghost" onClick={() => setWizardStep("validate")}> | ||
| 307 | Back | ||
| 308 | </Button> | ||
| 309 | <Button type="button" onClick={props.onAdded}> | ||
| 310 | Add connection | ||
| 311 | </Button> | ||
| 312 | </> | ||
| 313 | )} | ||
| 314 | </DialogFooter> | ||
| 315 | </DialogContent> | ||
| 316 | </Dialog> | ||
| 317 | ); | ||
| 318 | } | ||
| @@ -0,0 +1,261 @@ | |||
| 1 | // Reproduction of packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx | ||
| 2 | // in its post-analyze state, plus the AuthMethodListEditor block from | ||
| 3 | // packages/react/src/components/auth-method-list-editor.tsx. | ||
| 4 | // | ||
| 5 | // The locked "Method 1" panel is reproduced exactly: LockIcon, muted fill, | ||
| 6 | // `cursor-not-allowed select-none`, and a masked `Authorization: Bearer ••••••` | ||
| 7 | // placement line. It is a read-only echo of the spec's declared auth scheme — | ||
| 8 | // it takes no input — but it is the only credential-shaped thing on the page, | ||
| 9 | // and it appears at the moment the user has just copied an API key. | ||
| 10 | |||
| 11 | import { useState } from "react"; | ||
| 12 | import { LockIcon, XIcon, ChevronDownIcon } from "lucide-react"; | ||
| 13 | import { Button } from "@executor-js/react/components/button"; | ||
| 14 | import { Input } from "@executor-js/react/components/input"; | ||
| 15 | import { Label } from "@executor-js/react/components/label"; | ||
| 16 | import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; | ||
| 17 | import type { DemoPreset } from "../fixtures"; | ||
| 18 | |||
| 19 | function PlacementLine(props: { | ||
| 20 | readonly carrier: "header" | "query"; | ||
| 21 | readonly name: string; | ||
| 22 | readonly prefix: string; | ||
| 23 | }) { | ||
| 24 | const lead = props.carrier === "header" ? `${props.name}: ` : `?${props.name}=`; | ||
| 25 | return ( | ||
| 26 | <span className="wrap-anywhere whitespace-pre-wrap font-mono text-xs text-muted-foreground"> | ||
| 27 | {lead} | ||
| 28 | {props.prefix ? <span className="text-muted-foreground/60">{props.prefix}</span> : null} | ||
| 29 | <span className="tracking-widest text-primary">••••••</span> | ||
| 30 | </span> | ||
| 31 | ); | ||
| 32 | } | ||
| 33 | |||
| 34 | function FieldLabel(props: { readonly children: React.ReactNode }) { | ||
| 35 | return ( | ||
| 36 | <Label className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> | ||
| 37 | {props.children} | ||
| 38 | </Label> | ||
| 39 | ); | ||
| 40 | } | ||
| 41 | |||
| 42 | export function AddOpenApiPage(props: { | ||
| 43 | readonly preset: DemoPreset | null; | ||
| 44 | readonly onCancel: () => void; | ||
| 45 | readonly onComplete: () => void; | ||
| 46 | }) { | ||
| 47 | const [adding, setAdding] = useState(false); | ||
| 48 | const [overridesOpen, setOverridesOpen] = useState(false); | ||
| 49 | const [healthCheckOpen, setHealthCheckOpen] = useState(false); | ||
| 50 | const name = props.preset?.name ?? "API"; | ||
| 51 | const slug = props.preset?.id ?? "api"; | ||
| 52 | |||
| 53 | return ( | ||
| 54 | <div className="relative min-h-0 flex-1 overflow-y-auto"> | ||
| 55 | <div className="mx-auto flex min-h-full max-w-4xl flex-col gap-6 px-6 py-10 lg:px-10 lg:py-14"> | ||
| 56 | <div> | ||
| 57 | <h1 className="text-xl font-semibold text-foreground">Add OpenAPI integration</h1> | ||
| 58 | </div> | ||
| 59 | |||
| 60 | {/* Details — OpenApiIntegrationDetailsFields */} | ||
| 61 | <CardStack> | ||
| 62 | <CardStackContent className="border-t-0"> | ||
| 63 | <div className="flex items-start gap-3 px-4 py-4"> | ||
| 64 | {props.preset?.icon ? ( | ||
| 65 | <img src={props.preset.icon} alt="" className="size-8 shrink-0 object-contain" /> | ||
| 66 | ) : ( | ||
| 67 | <span className="size-8 shrink-0 rounded-md bg-muted" /> | ||
| 68 | )} | ||
| 69 | <div className="min-w-0"> | ||
| 70 | <p className="text-sm font-medium text-foreground">{name}</p> | ||
| 71 | <p className="text-xs text-muted-foreground">1.0.0 · 214 operations · 31 tags</p> | ||
| 72 | </div> | ||
| 73 | </div> | ||
| 74 | <div className="space-y-4 px-4 pb-4"> | ||
| 75 | <div className="space-y-2"> | ||
| 76 | <FieldLabel>Integration ID</FieldLabel> | ||
| 77 | <Input defaultValue={slug} className="font-mono text-sm" /> | ||
| 78 | </div> | ||
| 79 | <div className="space-y-2"> | ||
| 80 | <FieldLabel>Display name</FieldLabel> | ||
| 81 | <Input defaultValue={name} /> | ||
| 82 | </div> | ||
| 83 | <div className="space-y-2"> | ||
| 84 | <FieldLabel>Description</FieldLabel> | ||
| 85 | <Input defaultValue={props.preset?.summary ?? ""} /> | ||
| 86 | </div> | ||
| 87 | <div className="space-y-2"> | ||
| 88 | <FieldLabel>Base URL override (optional)</FieldLabel> | ||
| 89 | <Input placeholder="https://api.example.com" className="font-mono text-sm" /> | ||
| 90 | <p className="text-[11px] text-muted-foreground"> | ||
| 91 | Overrides the spec's servers; leave empty to choose the server (and | ||
| 92 | variables) per tool call. | ||
| 93 | </p> | ||
| 94 | </div> | ||
| 95 | <div className="space-y-2"> | ||
| 96 | <FieldLabel>OpenAPI spec</FieldLabel> | ||
| 97 | <Input defaultValue={props.preset?.url ?? ""} className="font-mono text-sm" /> | ||
| 98 | </div> | ||
| 99 | </div> | ||
| 100 | </CardStackContent> | ||
| 101 | </CardStack> | ||
| 102 | |||
| 103 | {/* SpecOverridesEditor — collapsed */} | ||
| 104 | <section className="space-y-2"> | ||
| 105 | {/* oxlint-disable-next-line react/forbid-elements */} | ||
| 106 | <button | ||
| 107 | type="button" | ||
| 108 | onClick={() => setOverridesOpen((open) => !open)} | ||
| 109 | className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" | ||
| 110 | > | ||
| 111 | Spec overrides (optional) | ||
| 112 | <ChevronDownIcon className={overridesOpen ? "size-3.5 rotate-180" : "size-3.5"} /> | ||
| 113 | </button> | ||
| 114 | {overridesOpen && ( | ||
| 115 | <div className="rounded-lg border border-border/60 bg-muted/20 p-3 font-mono text-xs text-muted-foreground"> | ||
| 116 | [] | ||
| 117 | </div> | ||
| 118 | )} | ||
| 119 | </section> | ||
| 120 | |||
| 121 | {/* AuthMethodListEditor */} | ||
| 122 | <section className="space-y-3"> | ||
| 123 | <div className="flex items-center justify-between"> | ||
| 124 | <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> | ||
| 125 | Authentication | ||
| 126 | </h3> | ||
| 127 | <Button type="button" variant="outline" size="sm"> | ||
| 128 | Add method | ||
| 129 | </Button> | ||
| 130 | </div> | ||
| 131 | |||
| 132 | <div className="flex flex-col gap-3"> | ||
| 133 | <div className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"> | ||
| 134 | <div className="flex items-center justify-between"> | ||
| 135 | <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> | ||
| 136 | <LockIcon className="size-3 shrink-0" aria-hidden /> | ||
| 137 | <span>Method 1</span> | ||
| 138 | </span> | ||
| 139 | <Button | ||
| 140 | type="button" | ||
| 141 | variant="ghost" | ||
| 142 | size="icon-sm" | ||
| 143 | aria-label="Remove method" | ||
| 144 | className="text-muted-foreground hover:text-foreground" | ||
| 145 | > | ||
| 146 | <XIcon /> | ||
| 147 | </Button> | ||
| 148 | </div> | ||
| 149 | <div className="space-y-2"> | ||
| 150 | <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground"> | ||
| 151 | API key | ||
| 152 | </p> | ||
| 153 | <div | ||
| 154 | aria-disabled | ||
| 155 | className="cursor-not-allowed select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground" | ||
| 156 | > | ||
| 157 | <div className="flex flex-wrap gap-x-4 gap-y-1"> | ||
| 158 | <PlacementLine carrier="header" name="Authorization" prefix="Bearer " /> | ||
| 159 | </div> | ||
| 160 | </div> | ||
| 161 | <p className="text-[11px] text-muted-foreground"> | ||
| 162 | Pulled from spec. Remove to override. | ||
| 163 | </p> | ||
| 164 | </div> | ||
| 165 | </div> | ||
| 166 | |||
| 167 | <div className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"> | ||
| 168 | <div className="flex items-center justify-between"> | ||
| 169 | <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> | ||
| 170 | <LockIcon className="size-3 shrink-0" aria-hidden /> | ||
| 171 | <span>Method 2</span> | ||
| 172 | </span> | ||
| 173 | <Button | ||
| 174 | type="button" | ||
| 175 | variant="ghost" | ||
| 176 | size="icon-sm" | ||
| 177 | aria-label="Remove method" | ||
| 178 | className="text-muted-foreground hover:text-foreground" | ||
| 179 | > | ||
| 180 | <XIcon /> | ||
| 181 | </Button> | ||
| 182 | </div> | ||
| 183 | <div className="space-y-2"> | ||
| 184 | <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground"> | ||
| 185 | OAuth | ||
| 186 | </p> | ||
| 187 | <div | ||
| 188 | aria-disabled | ||
| 189 | className="cursor-not-allowed select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground" | ||
| 190 | > | ||
| 191 | <div className="flex gap-2 text-xs"> | ||
| 192 | <span className="w-20 shrink-0 text-muted-foreground">Authorize</span> | ||
| 193 | <span className="break-all font-mono text-foreground/80"> | ||
| 194 | https://us.posthog.com/oauth/authorize | ||
| 195 | </span> | ||
| 196 | </div> | ||
| 197 | <div className="flex gap-2 text-xs"> | ||
| 198 | <span className="w-20 shrink-0 text-muted-foreground">Token</span> | ||
| 199 | <span className="break-all font-mono text-foreground/80"> | ||
| 200 | https://us.posthog.com/oauth/token | ||
| 201 | </span> | ||
| 202 | </div> | ||
| 203 | </div> | ||
| 204 | <p className="text-[11px] text-muted-foreground"> | ||
| 205 | Pulled from spec. Remove to override. | ||
| 206 | </p> | ||
| 207 | </div> | ||
| 208 | </div> | ||
| 209 | </div> | ||
| 210 | |||
| 211 | <p className="text-[11px] text-muted-foreground"> | ||
| 212 | Every method here is registered with the integration. Connect an account from the | ||
| 213 | integration page after adding. | ||
| 214 | </p> | ||
| 215 | </section> | ||
| 216 | |||
| 217 | {/* AddOpenApiHealthCheckSection */} | ||
| 218 | <section className="space-y-2"> | ||
| 219 | {/* oxlint-disable-next-line react/forbid-elements */} | ||
| 220 | <button | ||
| 221 | type="button" | ||
| 222 | onClick={() => setHealthCheckOpen((open) => !open)} | ||
| 223 | className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" | ||
| 224 | > | ||
| 225 | Health check (optional) | ||
| 226 | <ChevronDownIcon className={healthCheckOpen ? "size-3.5 rotate-180" : "size-3.5"} /> | ||
| 227 | </button> | ||
| 228 | {healthCheckOpen && ( | ||
| 229 | <div className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"> | ||
| 230 | <FieldLabel>Operation</FieldLabel> | ||
| 231 | <Input placeholder="Pick an operation to probe…" /> | ||
| 232 | </div> | ||
| 233 | )} | ||
| 234 | </section> | ||
| 235 | |||
| 236 | {/* FloatActions */} | ||
| 237 | <CardStack className="sticky bottom-4 left-0 right-0 mt-auto w-full transform-gpu shadow-lg"> | ||
| 238 | <div className="flex items-center justify-end gap-3 px-4 py-3"> | ||
| 239 | <Button variant="ghost" onClick={props.onCancel} disabled={adding}> | ||
| 240 | Cancel | ||
| 241 | </Button> | ||
| 242 | <Button | ||
| 243 | loading={adding} | ||
| 244 | onClick={() => { | ||
| 245 | setAdding(true); | ||
| 246 | // The real add is a spec parse + persist round trip; the button | ||
| 247 | // spins for as long as it takes, with nothing else to do. | ||
| 248 | setTimeout(() => { | ||
| 249 | setAdding(false); | ||
| 250 | props.onComplete(); | ||
| 251 | }, 1600); | ||
| 252 | }} | ||
| 253 | > | ||
| 254 | Add integration | ||
| 255 | </Button> | ||
| 256 | </div> | ||
| 257 | </CardStack> | ||
| 258 | </div> | ||
| 259 | </div> | ||
| 260 | ); | ||
| 261 | } | ||
| @@ -0,0 +1,279 @@ | |||
| 1 | // Reproduction of the ConnectDialog in packages/react/src/pages/integrations.tsx. | ||
| 2 | // | ||
| 3 | // Faithful to the parts that shape the experience: one input that silently | ||
| 4 | // switches between URL-detect and preset-filter, plugin chips above the list, | ||
| 5 | // a fixed 16rem scroll window over ~30 curated presets in plugin order, and | ||
| 6 | // the integrations.sh long tail that only appears once you have typed two | ||
| 7 | // characters. Picking anything leaves the dialog for a full-page form. | ||
| 8 | |||
| 9 | import { useEffect, useMemo, useRef, useState } from "react"; | ||
| 10 | import { Button } from "@executor-js/react/components/button"; | ||
| 11 | import { Input } from "@executor-js/react/components/input"; | ||
| 12 | import { Badge } from "@executor-js/react/components/badge"; | ||
| 13 | import { Skeleton } from "@executor-js/react/components/skeleton"; | ||
| 14 | import { | ||
| 15 | Dialog, | ||
| 16 | DialogContent, | ||
| 17 | DialogDescription, | ||
| 18 | DialogHeader, | ||
| 19 | DialogTitle, | ||
| 20 | } from "@executor-js/react/components/dialog"; | ||
| 21 | import { | ||
| 22 | CardStack, | ||
| 23 | CardStackContent, | ||
| 24 | CardStackEntry, | ||
| 25 | CardStackEntryActions, | ||
| 26 | CardStackEntryContent, | ||
| 27 | CardStackEntryDescription, | ||
| 28 | CardStackEntryTitle, | ||
| 29 | } from "@executor-js/react/components/card-stack"; | ||
| 30 | import { | ||
| 31 | catalogLogoUrl, | ||
| 32 | curatedPresets, | ||
| 33 | integrationPlugins, | ||
| 34 | searchCatalog, | ||
| 35 | type CatalogEntry, | ||
| 36 | type DemoPreset, | ||
| 37 | type PluginKey, | ||
| 38 | } from "../fixtures"; | ||
| 39 | |||
| 40 | const CATALOG_KIND_LABEL: Record<string, string> = { | ||
| 41 | mcp: "MCP", | ||
| 42 | openapi: "OpenAPI", | ||
| 43 | graphql: "GraphQL", | ||
| 44 | }; | ||
| 45 | |||
| 46 | const looksLikeUrl = (raw: string): boolean => { | ||
| 47 | const v = raw.trim(); | ||
| 48 | if (v.length === 0) return false; | ||
| 49 | if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true; | ||
| 50 | if (v.includes("/")) return true; | ||
| 51 | if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true; | ||
| 52 | return false; | ||
| 53 | }; | ||
| 54 | |||
| 55 | const SEARCH_DEBOUNCE_MS = 250; | ||
| 56 | const MIN_QUERY_LENGTH = 2; | ||
| 57 | |||
| 58 | function useCatalogSearch(rawQuery: string) { | ||
| 59 | const query = rawQuery.trim().toLowerCase(); | ||
| 60 | const [state, setState] = useState<{ | ||
| 61 | readonly entries: readonly CatalogEntry[]; | ||
| 62 | readonly loading: boolean; | ||
| 63 | }>({ entries: [], loading: false }); | ||
| 64 | const generation = useRef(0); | ||
| 65 | |||
| 66 | useEffect(() => { | ||
| 67 | const requestId = ++generation.current; | ||
| 68 | if (query.length < MIN_QUERY_LENGTH) { | ||
| 69 | setState({ entries: [], loading: false }); | ||
| 70 | return; | ||
| 71 | } | ||
| 72 | setState((previous) => ({ ...previous, loading: true })); | ||
| 73 | const timer = setTimeout(() => { | ||
| 74 | void searchCatalog(query).then((entries) => { | ||
| 75 | if (generation.current !== requestId) return; | ||
| 76 | setState({ entries, loading: false }); | ||
| 77 | }); | ||
| 78 | }, SEARCH_DEBOUNCE_MS); | ||
| 79 | return () => clearTimeout(timer); | ||
| 80 | }, [query]); | ||
| 81 | |||
| 82 | return state; | ||
| 83 | } | ||
| 84 | |||
| 85 | export function ConnectDialog(props: { | ||
| 86 | readonly open: boolean; | ||
| 87 | readonly onOpenChange: (open: boolean) => void; | ||
| 88 | readonly onPickPreset: (preset: DemoPreset) => void; | ||
| 89 | readonly onPickPlugin: (pluginKey: PluginKey) => void; | ||
| 90 | readonly onPickCatalogEntry: (entry: CatalogEntry) => void; | ||
| 91 | }) { | ||
| 92 | const [query, setQuery] = useState(""); | ||
| 93 | const isUrl = looksLikeUrl(query); | ||
| 94 | const presetSearch = isUrl ? "" : query; | ||
| 95 | |||
| 96 | const filtered = useMemo(() => { | ||
| 97 | const q = presetSearch.trim().toLowerCase(); | ||
| 98 | if (q.length === 0) return curatedPresets; | ||
| 99 | return curatedPresets.filter(({ name, summary, pluginLabel }) => | ||
| 100 | `${name} ${summary} ${pluginLabel}`.toLowerCase().includes(q), | ||
| 101 | ); | ||
| 102 | }, [presetSearch]); | ||
| 103 | |||
| 104 | const catalog = useCatalogSearch(presetSearch); | ||
| 105 | const showCatalogSection = | ||
| 106 | presetSearch.trim().length > 0 && (catalog.entries.length > 0 || catalog.loading); | ||
| 107 | |||
| 108 | const close = () => { | ||
| 109 | setQuery(""); | ||
| 110 | props.onOpenChange(false); | ||
| 111 | }; | ||
| 112 | |||
| 113 | return ( | ||
| 114 | <Dialog | ||
| 115 | open={props.open} | ||
| 116 | onOpenChange={(open: boolean) => { | ||
| 117 | if (!open) close(); | ||
| 118 | else props.onOpenChange(open); | ||
| 119 | }} | ||
| 120 | > | ||
| 121 | <DialogContent className="sm:max-w-[560px]"> | ||
| 122 | <DialogHeader> | ||
| 123 | <DialogTitle>Connect an integration</DialogTitle> | ||
| 124 | <DialogDescription> | ||
| 125 | Search the preset library, or paste a URL to auto-detect. | ||
| 126 | </DialogDescription> | ||
| 127 | </DialogHeader> | ||
| 128 | |||
| 129 | <div className="flex min-w-0 flex-col gap-5"> | ||
| 130 | <div className="flex flex-col gap-2"> | ||
| 131 | <div className="flex gap-2"> | ||
| 132 | <Input | ||
| 133 | type="text" | ||
| 134 | value={query} | ||
| 135 | onChange={(event) => setQuery((event.target as HTMLInputElement).value)} | ||
| 136 | placeholder="Search or paste a URL…" | ||
| 137 | className="flex-1" | ||
| 138 | /> | ||
| 139 | {isUrl && <Button disabled={query.trim().length === 0}>Detect</Button>} | ||
| 140 | </div> | ||
| 141 | </div> | ||
| 142 | |||
| 143 | <div className="flex flex-col gap-2"> | ||
| 144 | <p className="text-xs font-medium text-foreground/80">Or add manually</p> | ||
| 145 | <div className="flex flex-wrap gap-2"> | ||
| 146 | {integrationPlugins.map((plugin) => ( | ||
| 147 | // oxlint-disable-next-line react/forbid-elements | ||
| 148 | <button | ||
| 149 | key={plugin.key} | ||
| 150 | type="button" | ||
| 151 | onClick={() => { | ||
| 152 | close(); | ||
| 153 | props.onPickPlugin(plugin.key); | ||
| 154 | }} | ||
| 155 | className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted" | ||
| 156 | > | ||
| 157 | {plugin.label} | ||
| 158 | </button> | ||
| 159 | ))} | ||
| 160 | </div> | ||
| 161 | </div> | ||
| 162 | |||
| 163 | <div className="flex min-w-0 flex-col gap-2"> | ||
| 164 | <p className="text-xs font-medium text-foreground/80">Popular integrations</p> | ||
| 165 | <CardStack className="min-w-0"> | ||
| 166 | <CardStackContent className="h-64 overflow-y-auto"> | ||
| 167 | {filtered.length === 0 && !showCatalogSection ? ( | ||
| 168 | <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-6 text-center"> | ||
| 169 | <p className="text-sm text-muted-foreground">No matching integrations</p> | ||
| 170 | <p className="text-xs text-muted-foreground/70"> | ||
| 171 | Paste a URL above to auto-detect, or pick an integration type manually. | ||
| 172 | </p> | ||
| 173 | </div> | ||
| 174 | ) : ( | ||
| 175 | filtered.map((preset) => ( | ||
| 176 | <CardStackEntry key={`${preset.pluginKey}-${preset.id}`} asChild> | ||
| 177 | {/* oxlint-disable-next-line react/forbid-elements */} | ||
| 178 | <button | ||
| 179 | type="button" | ||
| 180 | className="w-full text-left" | ||
| 181 | onClick={() => { | ||
| 182 | close(); | ||
| 183 | props.onPickPreset(preset); | ||
| 184 | }} | ||
| 185 | > | ||
| 186 | <span className="flex size-8 shrink-0 items-center justify-center"> | ||
| 187 | {preset.icon ? ( | ||
| 188 | <img | ||
| 189 | src={preset.icon} | ||
| 190 | alt="" | ||
| 191 | className="size-5 object-contain" | ||
| 192 | loading="lazy" | ||
| 193 | /> | ||
| 194 | ) : ( | ||
| 195 | <svg viewBox="0 0 16 16" className="size-3.5" fill="none"> | ||
| 196 | <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" /> | ||
| 197 | </svg> | ||
| 198 | )} | ||
| 199 | </span> | ||
| 200 | <CardStackEntryContent> | ||
| 201 | <CardStackEntryTitle>{preset.name}</CardStackEntryTitle> | ||
| 202 | <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription> | ||
| 203 | </CardStackEntryContent> | ||
| 204 | <CardStackEntryActions> | ||
| 205 | <Badge variant="secondary">{preset.pluginLabel}</Badge> | ||
| 206 | </CardStackEntryActions> | ||
| 207 | </button> | ||
| 208 | </CardStackEntry> | ||
| 209 | )) | ||
| 210 | )} | ||
| 211 | |||
| 212 | {showCatalogSection && ( | ||
| 213 | <> | ||
| 214 | {catalog.entries.map((entry) => ( | ||
| 215 | <CardStackEntry key={`catalog-${entry.domain}`} asChild> | ||
| 216 | {/* oxlint-disable-next-line react/forbid-elements */} | ||
| 217 | <button | ||
| 218 | type="button" | ||
| 219 | className="w-full text-left" | ||
| 220 | onClick={() => { | ||
| 221 | close(); | ||
| 222 | props.onPickCatalogEntry(entry); | ||
| 223 | }} | ||
| 224 | > | ||
| 225 | <span className="flex size-8 shrink-0 items-center justify-center"> | ||
| 226 | <img | ||
| 227 | src={catalogLogoUrl(entry.domain, 10)} | ||
| 228 | alt="" | ||
| 229 | className="size-5 object-contain" | ||
| 230 | loading="lazy" | ||
| 231 | /> | ||
| 232 | </span> | ||
| 233 | <CardStackEntryContent> | ||
| 234 | <CardStackEntryTitle>{entry.domain}</CardStackEntryTitle> | ||
| 235 | <CardStackEntryDescription> | ||
| 236 | {entry.description} | ||
| 237 | </CardStackEntryDescription> | ||
| 238 | </CardStackEntryContent> | ||
| 239 | <CardStackEntryActions> | ||
| 240 | {entry.kinds.map((kind) => ( | ||
| 241 | <Badge key={kind} variant="secondary"> | ||
| 242 | {CATALOG_KIND_LABEL[kind]} | ||
| 243 | </Badge> | ||
| 244 | ))} | ||
| 245 | </CardStackEntryActions> | ||
| 246 | </button> | ||
| 247 | </CardStackEntry> | ||
| 248 | ))} | ||
| 249 | {catalog.loading && | ||
| 250 | catalog.entries.length === 0 && | ||
| 251 | Array.from({ length: 3 }).map((_, i) => ( | ||
| 252 | <div | ||
| 253 | key={`catalog-skeleton-${i}`} | ||
| 254 | className="flex items-center gap-3 px-4 py-3" | ||
| 255 | > | ||
| 256 | <Skeleton className="size-5 shrink-0 rounded-md" /> | ||
| 257 | <div className="flex min-w-0 flex-1 flex-col gap-1.5"> | ||
| 258 | <Skeleton | ||
| 259 | className="h-3.5" | ||
| 260 | style={{ width: `${35 + ((i * 13) % 25)}%` }} | ||
| 261 | /> | ||
| 262 | <Skeleton | ||
| 263 | className="h-3" | ||
| 264 | style={{ width: `${55 + ((i * 9) % 20)}%` }} | ||
| 265 | /> | ||
| 266 | </div> | ||
| 267 | <Skeleton className="h-5 w-14 rounded-full" /> | ||
| 268 | </div> | ||
| 269 | ))} | ||
| 270 | </> | ||
| 271 | )} | ||
| 272 | </CardStackContent> | ||
| 273 | </CardStack> | ||
| 274 | </div> | ||
| 275 | </div> | ||
| 276 | </DialogContent> | ||
| 277 | </Dialog> | ||
| 278 | ); | ||
| 279 | } | ||
| @@ -0,0 +1,105 @@ | |||
| 1 | // Reproduction of packages/react/src/pages/integration-detail.tsx plus the | ||
| 2 | // AccountsSection from packages/react/src/components/accounts-section.tsx. | ||
| 3 | // | ||
| 4 | // This is where a just-added integration lands. It has an Edit / Refresh / | ||
| 5 | // Delete header, an Accounts / Tools tab strip, a "Connections" heading, and a | ||
| 6 | // dashed empty card whose CTA is "Add connection" — the second add, for a thing | ||
| 7 | // the user did not know was separate from the first. | ||
| 8 | |||
| 9 | import { useState } from "react"; | ||
| 10 | import { Button } from "@executor-js/react/components/button"; | ||
| 11 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; | ||
| 12 | import type { DemoIntegration } from "../fixtures"; | ||
| 13 | |||
| 14 | export function IntegrationDetailPage(props: { | ||
| 15 | readonly integration: DemoIntegration; | ||
| 16 | readonly onAddConnection: () => void; | ||
| 17 | }) { | ||
| 18 | const [tab, setTab] = useState<"accounts" | "tools">("accounts"); | ||
| 19 | |||
| 20 | return ( | ||
| 21 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> | ||
| 22 | <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm"> | ||
| 23 | <div className="flex min-w-0 items-center gap-3"> | ||
| 24 | <h2 className="truncate text-sm font-semibold text-foreground"> | ||
| 25 | {props.integration.name} | ||
| 26 | </h2> | ||
| 27 | <span className="hidden text-xs tabular-nums text-muted-foreground sm:block"> | ||
| 28 | {props.integration.toolCount} tools | ||
| 29 | </span> | ||
| 30 | </div> | ||
| 31 | <div className="flex shrink-0 items-center gap-2"> | ||
| 32 | <Button variant="outline" size="sm"> | ||
| 33 | Edit | ||
| 34 | </Button> | ||
| 35 | <Button variant="outline" size="sm"> | ||
| 36 | Refresh | ||
| 37 | </Button> | ||
| 38 | <Button | ||
| 39 | variant="outline" | ||
| 40 | size="sm" | ||
| 41 | className="border-destructive/30 text-destructive hover:bg-destructive/10" | ||
| 42 | > | ||
| 43 | Delete | ||
| 44 | </Button> | ||
| 45 | </div> | ||
| 46 | </div> | ||
| 47 | |||
| 48 | <Tabs | ||
| 49 | value={tab} | ||
| 50 | onValueChange={(value: string) => setTab(value === "tools" ? "tools" : "accounts")} | ||
| 51 | className="min-h-0 flex-1 gap-0 overflow-hidden" | ||
| 52 | > | ||
| 53 | <div className="shrink-0 border-b border-border/60 px-4 py-2"> | ||
| 54 | <TabsList variant="line"> | ||
| 55 | <TabsTrigger value="accounts">Accounts</TabsTrigger> | ||
| 56 | <TabsTrigger value="tools">Tools</TabsTrigger> | ||
| 57 | </TabsList> | ||
| 58 | </div> | ||
| 59 | |||
| 60 | <TabsContent value="accounts" className="min-h-0 overflow-y-auto"> | ||
| 61 | <div className="mx-auto max-w-3xl space-y-8 px-6 py-8"> | ||
| 62 | <section className="space-y-3"> | ||
| 63 | <div className="flex items-center justify-between"> | ||
| 64 | <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> | ||
| 65 | Connections | ||
| 66 | </h3> | ||
| 67 | </div> | ||
| 68 | <div className="rounded-lg border border-dashed border-border/60 px-6 py-8 text-center"> | ||
| 69 | <p className="text-sm font-medium text-foreground">No connections yet</p> | ||
| 70 | <p className="mt-1 text-sm text-muted-foreground"> | ||
| 71 | Add a connection to make this integration's tools available. | ||
| 72 | </p> | ||
| 73 | <Button type="button" className="mt-4" size="sm" onClick={props.onAddConnection}> | ||
| 74 | Add connection | ||
| 75 | </Button> | ||
| 76 | </div> | ||
| 77 | </section> | ||
| 78 | </div> | ||
| 79 | </TabsContent> | ||
| 80 | |||
| 81 | <TabsContent | ||
| 82 | value="tools" | ||
| 83 | className="flex min-h-0 flex-col overflow-hidden data-[state=inactive]:hidden" | ||
| 84 | > | ||
| 85 | <div className="flex min-h-0 flex-1 overflow-hidden"> | ||
| 86 | <div className="flex w-72 shrink-0 flex-col border-r border-border/60 p-3 lg:w-80 xl:w-[22rem]"> | ||
| 87 | <p className="px-2 py-1.5 text-xs text-muted-foreground">No tools yet</p> | ||
| 88 | </div> | ||
| 89 | <div className="flex min-w-0 flex-1 items-center justify-center"> | ||
| 90 | <div className="max-w-sm text-center"> | ||
| 91 | <p className="text-sm font-medium text-foreground">No tools yet</p> | ||
| 92 | <p className="mt-1.5 text-sm text-muted-foreground"> | ||
| 93 | Add a connection to unlock this integration's tools. | ||
| 94 | </p> | ||
| 95 | <Button type="button" size="sm" className="mt-4" onClick={props.onAddConnection}> | ||
| 96 | Add connection | ||
| 97 | </Button> | ||
| 98 | </div> | ||
| 99 | </div> | ||
| 100 | </div> | ||
| 101 | </TabsContent> | ||
| 102 | </Tabs> | ||
| 103 | </div> | ||
| 104 | ); | ||
| 105 | } | ||
| @@ -0,0 +1,142 @@ | |||
| 1 | // Reproduction of packages/react/src/pages/integrations.tsx — the screen that | ||
| 2 | // follows CLI setup. The "Connect an agent" card sits above the fold; finding | ||
| 3 | // an integration is a dialog behind one button in the header. | ||
| 4 | |||
| 5 | import { PlusIcon } from "lucide-react"; | ||
| 6 | import { Button } from "@executor-js/react/components/button"; | ||
| 7 | import { PageContainer, PageHeader } from "@executor-js/react/components/page"; | ||
| 8 | import { | ||
| 9 | CardStack, | ||
| 10 | CardStackContent, | ||
| 11 | CardStackEntry, | ||
| 12 | CardStackEntryActions, | ||
| 13 | CardStackEntryContent, | ||
| 14 | CardStackEntryDescription, | ||
| 15 | CardStackEntryTitle, | ||
| 16 | CardStackHeader, | ||
| 17 | } from "@executor-js/react/components/card-stack"; | ||
| 18 | import { Badge } from "@executor-js/react/components/badge"; | ||
| 19 | import { Tabs, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; | ||
| 20 | import type { DemoIntegration } from "../fixtures"; | ||
| 21 | |||
| 22 | function McpInstallCard() { | ||
| 23 | return ( | ||
| 24 | <CardStack> | ||
| 25 | <Tabs value="http"> | ||
| 26 | <CardStackHeader | ||
| 27 | className="items-start py-4" | ||
| 28 | rightSlot={ | ||
| 29 | <TabsList> | ||
| 30 | <TabsTrigger value="http">Remote HTTP</TabsTrigger> | ||
| 31 | <TabsTrigger value="stdio">Standard I/O</TabsTrigger> | ||
| 32 | </TabsList> | ||
| 33 | } | ||
| 34 | > | ||
| 35 | <div className="flex min-w-0 flex-col gap-0.5"> | ||
| 36 | <span className="text-sm font-semibold text-foreground">Connect an agent</span> | ||
| 37 | <span className="text-xs font-normal text-muted-foreground"> | ||
| 38 | Paste this into Claude Code, Cursor, or any MCP client, and your agent gets every tool | ||
| 39 | you connect here. | ||
| 40 | </span> | ||
| 41 | </div> | ||
| 42 | </CardStackHeader> | ||
| 43 | <CardStackContent> | ||
| 44 | <div className="px-4 pt-3 pb-3"> | ||
| 45 | <div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 font-mono text-xs text-foreground"> | ||
| 46 | npx add-mcp http://localhost:3123/mcp --transport http --name executor | ||
| 47 | </div> | ||
| 48 | <div className="mt-3 text-xs font-medium text-muted-foreground">Advanced ⌄</div> | ||
| 49 | </div> | ||
| 50 | <div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground"> | ||
| 51 | <span>Work with your agent</span> | ||
| 52 | <span className="rounded-md border border-border/60 px-1.5 py-0.5">Cursor</span> | ||
| 53 | <span className="rounded-md border border-border/60 px-1.5 py-0.5">Claude</span> | ||
| 54 | <span className="rounded-md border border-border/60 px-1.5 py-0.5">OpenCode</span> | ||
| 55 | <span>and more</span> | ||
| 56 | </div> | ||
| 57 | </CardStackContent> | ||
| 58 | </Tabs> | ||
| 59 | </CardStack> | ||
| 60 | ); | ||
| 61 | } | ||
| 62 | |||
| 63 | export function IntegrationsPage(props: { | ||
| 64 | readonly integrations: readonly DemoIntegration[]; | ||
| 65 | readonly onConnect: () => void; | ||
| 66 | readonly onOpenIntegration: (slug: string) => void; | ||
| 67 | }) { | ||
| 68 | return ( | ||
| 69 | <PageContainer> | ||
| 70 | <PageHeader | ||
| 71 | title="Integrations" | ||
| 72 | description="Tool providers available in this workspace." | ||
| 73 | actions={ | ||
| 74 | <Button onClick={props.onConnect} size="sm" className="gap-1.5"> | ||
| 75 | <PlusIcon className="size-4" /> | ||
| 76 | Connect | ||
| 77 | </Button> | ||
| 78 | } | ||
| 79 | /> | ||
| 80 | |||
| 81 | <div className="mb-8"> | ||
| 82 | <McpInstallCard /> | ||
| 83 | </div> | ||
| 84 | |||
| 85 | <div className="mb-8 border-t border-border/50" /> | ||
| 86 | |||
| 87 | {props.integrations.length === 0 ? ( | ||
| 88 | <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16"> | ||
| 89 | <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground"> | ||
| 90 | <PlusIcon className="size-5" /> | ||
| 91 | </div> | ||
| 92 | <p className="mb-1 text-[14px] font-medium text-foreground/70">No integrations yet</p> | ||
| 93 | <p className="mb-5 text-[13px] text-muted-foreground/60"> | ||
| 94 | Connect an integration to start curating tools. | ||
| 95 | </p> | ||
| 96 | <Button onClick={props.onConnect} size="sm" className="gap-1.5"> | ||
| 97 | <PlusIcon className="size-4" /> | ||
| 98 | Connect an integration | ||
| 99 | </Button> | ||
| 100 | </div> | ||
| 101 | ) : ( | ||
| 102 | <div className="mb-8 space-y-3"> | ||
| 103 | <CardStack searchable> | ||
| 104 | <CardStackContent> | ||
| 105 | {props.integrations.map((integration) => ( | ||
| 106 | <CardStackEntry key={integration.slug} asChild> | ||
| 107 | {/* oxlint-disable-next-line react/forbid-elements */} | ||
| 108 | <button | ||
| 109 | type="button" | ||
| 110 | className="w-full text-left" | ||
| 111 | onClick={() => props.onOpenIntegration(integration.slug)} | ||
| 112 | > | ||
| 113 | {integration.icon ? ( | ||
| 114 | <img | ||
| 115 | src={integration.icon} | ||
| 116 | alt="" | ||
| 117 | className="size-8 shrink-0 rounded-md object-contain" | ||
| 118 | /> | ||
| 119 | ) : ( | ||
| 120 | <span className="size-8 shrink-0 rounded-md bg-muted" /> | ||
| 121 | )} | ||
| 122 | <CardStackEntryContent> | ||
| 123 | <CardStackEntryTitle>{integration.name}</CardStackEntryTitle> | ||
| 124 | <CardStackEntryDescription>{integration.slug}</CardStackEntryDescription> | ||
| 125 | </CardStackEntryContent> | ||
| 126 | <CardStackEntryActions> | ||
| 127 | <Badge variant="outline"> | ||
| 128 | {integration.toolCount === 0 | ||
| 129 | ? "No connection" | ||
| 130 | : `${integration.toolCount} tools`} | ||
| 131 | </Badge> | ||
| 132 | </CardStackEntryActions> | ||
| 133 | </button> | ||
| 134 | </CardStackEntry> | ||
| 135 | ))} | ||
| 136 | </CardStackContent> | ||
| 137 | </CardStack> | ||
| 138 | </div> | ||
| 139 | )} | ||
| 140 | </PageContainer> | ||
| 141 | ); | ||
| 142 | } | ||
| @@ -0,0 +1,229 @@ | |||
| 1 | // Reworked: authenticating one account. | ||
| 2 | // | ||
| 3 | // The whole screen is built from the registry's own credential record, which | ||
| 4 | // integrations.sh already returns and the console currently discards: | ||
| 5 | // credentials[].label → what this key is called at the provider | ||
| 6 | // credentials[].generateUrl → the page that mints it, as a real link | ||
| 7 | // credentials[].setup → how to mint it, in the provider's own terms | ||
| 8 | // | ||
| 9 | // So the three questions the current modal leaves unanswered — what kind of key, | ||
| 10 | // where do I get one, where does it go — are answered on the screen that asks | ||
| 11 | // for it. The paste field is the first focusable thing and is never gated: a | ||
| 12 | // key already on the clipboard can go in immediately. | ||
| 13 | |||
| 14 | import { useEffect, useState } from "react"; | ||
| 15 | import { ArrowUpRightIcon, EyeIcon, EyeOffIcon } from "lucide-react"; | ||
| 16 | import { Button } from "@executor-js/react/components/button"; | ||
| 17 | import { Label } from "@executor-js/react/components/label"; | ||
| 18 | import { | ||
| 19 | Dialog, | ||
| 20 | DialogContent, | ||
| 21 | DialogDescription, | ||
| 22 | DialogFooter, | ||
| 23 | DialogHeader, | ||
| 24 | DialogTitle, | ||
| 25 | } from "@executor-js/react/components/dialog"; | ||
| 26 | import { fetchSurfaceCredentials, type SurfaceCredential } from "../../fixtures"; | ||
| 27 | |||
| 28 | /** The registry's setup text is markdown. Only links and bold appear in | ||
| 29 | * practice, so render those two and leave the rest as text rather than | ||
| 30 | * pulling in a markdown pipeline for a prototype. */ | ||
| 31 | function SetupText(props: { readonly text: string }) { | ||
| 32 | const nodes = props.text | ||
| 33 | .split(/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`)/g) | ||
| 34 | .filter((chunk) => chunk.length > 0) | ||
| 35 | .map((chunk, index) => { | ||
| 36 | const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(chunk); | ||
| 37 | if (link) { | ||
| 38 | return ( | ||
| 39 | <a | ||
| 40 | key={index} | ||
| 41 | href={link[2]} | ||
| 42 | target="_blank" | ||
| 43 | rel="noreferrer" | ||
| 44 | className="font-medium text-foreground underline underline-offset-2" | ||
| 45 | > | ||
| 46 | {link[1]} | ||
| 47 | </a> | ||
| 48 | ); | ||
| 49 | } | ||
| 50 | const bold = /^\*\*([^*]+)\*\*$/.exec(chunk); | ||
| 51 | if (bold) { | ||
| 52 | return ( | ||
| 53 | <span key={index} className="font-medium text-foreground"> | ||
| 54 | {bold[1]} | ||
| 55 | </span> | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | const code = /^`([^`]+)`$/.exec(chunk); | ||
| 59 | if (code) { | ||
| 60 | return ( | ||
| 61 | <code key={index} className="font-mono text-[0.9em] text-foreground"> | ||
| 62 | {code[1]} | ||
| 63 | </code> | ||
| 64 | ); | ||
| 65 | } | ||
| 66 | return <span key={index}>{chunk}</span>; | ||
| 67 | }); | ||
| 68 | return <p className="text-xs leading-relaxed text-muted-foreground">{nodes}</p>; | ||
| 69 | } | ||
| 70 | |||
| 71 | export function AuthenticateDialog(props: { | ||
| 72 | readonly open: boolean; | ||
| 73 | readonly onOpenChange: (open: boolean) => void; | ||
| 74 | readonly integrationName: string; | ||
| 75 | readonly domain: string; | ||
| 76 | readonly accountLabel: string; | ||
| 77 | readonly onAuthenticated: () => void; | ||
| 78 | }) { | ||
| 79 | const [credentials, setCredentials] = useState<readonly SurfaceCredential[] | null>(null); | ||
| 80 | const [selectedId, setSelectedId] = useState<string | null>(null); | ||
| 81 | const [secret, setSecret] = useState(""); | ||
| 82 | const [revealed, setRevealed] = useState(false); | ||
| 83 | const [submitting, setSubmitting] = useState(false); | ||
| 84 | |||
| 85 | useEffect(() => { | ||
| 86 | let live = true; | ||
| 87 | void fetchSurfaceCredentials(props.domain).then((loaded) => { | ||
| 88 | if (!live) return; | ||
| 89 | setCredentials(loaded); | ||
| 90 | setSelectedId(loaded[0]?.id ?? null); | ||
| 91 | }); | ||
| 92 | return () => { | ||
| 93 | live = false; | ||
| 94 | }; | ||
| 95 | }, [props.domain]); | ||
| 96 | |||
| 97 | const selected = credentials?.find((candidate) => candidate.id === selectedId) ?? null; | ||
| 98 | const isOAuth = selected?.type === "oauth2"; | ||
| 99 | const canSubmit = isOAuth || secret.trim().length > 0; | ||
| 100 | |||
| 101 | return ( | ||
| 102 | <Dialog open={props.open} onOpenChange={props.onOpenChange}> | ||
| 103 | <DialogContent className="sm:max-w-lg"> | ||
| 104 | <DialogHeader> | ||
| 105 | <DialogTitle>Connect {props.integrationName}</DialogTitle> | ||
| 106 | <DialogDescription> | ||
| 107 | Signing in as <span className="font-mono text-foreground">{props.accountLabel}</span>. | ||
| 108 | </DialogDescription> | ||
| 109 | </DialogHeader> | ||
| 110 | |||
| 111 | {credentials === null ? ( | ||
| 112 | <p className="py-6 text-sm text-muted-foreground"> | ||
| 113 | Checking how {props.domain} signs in… | ||
| 114 | </p> | ||
| 115 | ) : credentials.length === 0 ? ( | ||
| 116 | <p className="py-6 text-sm text-muted-foreground"> | ||
| 117 | The registry has no credential record for {props.domain} yet. | ||
| 118 | </p> | ||
| 119 | ) : ( | ||
| 120 | <div className="flex flex-col gap-4"> | ||
| 121 | {/* Only shown when the provider genuinely offers a choice. One way | ||
| 122 | in renders no picker at all. */} | ||
| 123 | {credentials.length > 1 && ( | ||
| 124 | <div className="flex gap-1.5"> | ||
| 125 | {credentials.map((credential) => ( | ||
| 126 | // oxlint-disable-next-line react/forbid-elements | ||
| 127 | <button | ||
| 128 | key={credential.id} | ||
| 129 | type="button" | ||
| 130 | onClick={() => setSelectedId(credential.id)} | ||
| 131 | className={ | ||
| 132 | credential.id === selectedId | ||
| 133 | ? "rounded-full border border-foreground/20 bg-foreground px-3 py-1 text-xs font-medium text-background" | ||
| 134 | : "rounded-full border border-border px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground" | ||
| 135 | } | ||
| 136 | > | ||
| 137 | {credential.label} | ||
| 138 | </button> | ||
| 139 | ))} | ||
| 140 | </div> | ||
| 141 | )} | ||
| 142 | |||
| 143 | {isOAuth ? ( | ||
| 144 | <p className="text-xs leading-relaxed text-muted-foreground"> | ||
| 145 | {props.integrationName} signs in through your browser. Nothing to paste. | ||
| 146 | </p> | ||
| 147 | ) : ( | ||
| 148 | <> | ||
| 149 | <div className="space-y-2"> | ||
| 150 | <div className="flex items-center justify-between gap-3"> | ||
| 151 | <Label | ||
| 152 | htmlFor="credential" | ||
| 153 | className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground" | ||
| 154 | > | ||
| 155 | {selected?.label ?? "API key"} | ||
| 156 | </Label> | ||
| 157 | {selected?.generateUrl ? ( | ||
| 158 | <a | ||
| 159 | href={selected.generateUrl} | ||
| 160 | target="_blank" | ||
| 161 | rel="noreferrer" | ||
| 162 | className="flex items-center gap-1 text-xs font-medium text-foreground underline underline-offset-2" | ||
| 163 | > | ||
| 164 | Get a key | ||
| 165 | <ArrowUpRightIcon className="size-3" aria-hidden /> | ||
| 166 | </a> | ||
| 167 | ) : null} | ||
| 168 | </div> | ||
| 169 | <div className="flex items-center gap-1"> | ||
| 170 | {/* oxlint-disable-next-line react/forbid-elements */} | ||
| 171 | <input | ||
| 172 | id="credential" | ||
| 173 | type={revealed ? "text" : "password"} | ||
| 174 | autoComplete="off" | ||
| 175 | // The one thing this screen is for. | ||
| 176 | // oxlint-disable-next-line jsx_a11y/no-autofocus | ||
| 177 | autoFocus | ||
| 178 | value={secret} | ||
| 179 | onChange={(event) => setSecret(event.target.value)} | ||
| 180 | placeholder="Paste your key" | ||
| 181 | className="h-10 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 font-mono text-sm outline-none transition-colors focus:border-ring dark:bg-input/30" | ||
| 182 | /> | ||
| 183 | <Button | ||
| 184 | type="button" | ||
| 185 | variant="ghost" | ||
| 186 | size="icon-sm" | ||
| 187 | aria-label="Show key" | ||
| 188 | aria-pressed={revealed} | ||
| 189 | className="shrink-0 text-muted-foreground" | ||
| 190 | onClick={() => setRevealed((value) => !value)} | ||
| 191 | > | ||
| 192 | {revealed ? <EyeOffIcon /> : <EyeIcon />} | ||
| 193 | </Button> | ||
| 194 | </div> | ||
| 195 | </div> | ||
| 196 | |||
| 197 | {selected?.setup ? ( | ||
| 198 | <div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3"> | ||
| 199 | <SetupText text={selected.setup} /> | ||
| 200 | </div> | ||
| 201 | ) : null} | ||
| 202 | </> | ||
| 203 | )} | ||
| 204 | </div> | ||
| 205 | )} | ||
| 206 | |||
| 207 | <DialogFooter> | ||
| 208 | <Button type="button" variant="ghost" onClick={() => props.onOpenChange(false)}> | ||
| 209 | Cancel | ||
| 210 | </Button> | ||
| 211 | <Button | ||
| 212 | type="button" | ||
| 213 | disabled={!canSubmit || submitting} | ||
| 214 | loading={submitting} | ||
| 215 | onClick={() => { | ||
| 216 | setSubmitting(true); | ||
| 217 | setTimeout(() => { | ||
| 218 | setSubmitting(false); | ||
| 219 | props.onAuthenticated(); | ||
| 220 | }, 700); | ||
| 221 | }} | ||
| 222 | > | ||
| 223 | {isOAuth ? `Continue to ${props.integrationName}` : "Connect"} | ||
| 224 | </Button> | ||
| 225 | </DialogFooter> | ||
| 226 | </DialogContent> | ||
| 227 | </Dialog> | ||
| 228 | ); | ||
| 229 | } | ||
| @@ -0,0 +1,234 @@ | |||
| 1 | // Reworked: the picker. | ||
| 2 | // | ||
| 3 | // Not a dialog behind a header button — the page itself. Search is the first | ||
| 4 | // thing on it, categories are chips under that, and every row adds in place: | ||
| 5 | // the row flips to "Added", the list does not move, and you can keep adding. | ||
| 6 | // Nothing here asks for a spec URL, a base URL or an auth method, because the | ||
| 7 | // registry already knows all three. | ||
| 8 | // | ||
| 9 | // "Add custom" sits next to the search box at all times, and the empty-search | ||
| 10 | // state leads with it rather than apologising — a URL that isn't in the | ||
| 11 | // registry is a normal thing to have, not a failed search. | ||
| 12 | |||
| 13 | import { useEffect, useMemo, useState } from "react"; | ||
| 14 | import { CheckIcon, PlusIcon, SearchIcon } from "lucide-react"; | ||
| 15 | import { Button } from "@executor-js/react/components/button"; | ||
| 16 | import { Input } from "@executor-js/react/components/input"; | ||
| 17 | import { Skeleton } from "@executor-js/react/components/skeleton"; | ||
| 18 | import { cn } from "@executor-js/react/lib/utils"; | ||
| 19 | import { CATEGORY_ORDER, loadCatalog, searchItems, type CatalogItem } from "../../catalog"; | ||
| 20 | |||
| 21 | const SECTION_SIZE = 6; | ||
| 22 | |||
| 23 | function AddButton(props: { readonly added: boolean; readonly onAdd: () => void }) { | ||
| 24 | if (props.added) { | ||
| 25 | return ( | ||
| 26 | <span className="flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-xs font-medium text-muted-foreground"> | ||
| 27 | <CheckIcon className="size-3.5" aria-hidden /> | ||
| 28 | Added | ||
| 29 | </span> | ||
| 30 | ); | ||
| 31 | } | ||
| 32 | return ( | ||
| 33 | <Button type="button" variant="outline" size="sm" className="shrink-0" onClick={props.onAdd}> | ||
| 34 | Add | ||
| 35 | </Button> | ||
| 36 | ); | ||
| 37 | } | ||
| 38 | |||
| 39 | function CatalogRow(props: { | ||
| 40 | readonly item: CatalogItem; | ||
| 41 | readonly added: boolean; | ||
| 42 | readonly onAdd: () => void; | ||
| 43 | readonly onOpen: () => void; | ||
| 44 | }) { | ||
| 45 | return ( | ||
| 46 | <div | ||
| 47 | className={cn( | ||
| 48 | "flex items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors", | ||
| 49 | props.added ? "border-ring/40 bg-accent/40" : "border-transparent hover:bg-accent/40", | ||
| 50 | )} | ||
| 51 | > | ||
| 52 | {/* oxlint-disable-next-line react/forbid-elements */} | ||
| 53 | <button | ||
| 54 | type="button" | ||
| 55 | onClick={props.onOpen} | ||
| 56 | className="flex min-w-0 flex-1 items-center gap-3 text-left" | ||
| 57 | > | ||
| 58 | <img | ||
| 59 | src={props.item.icon} | ||
| 60 | alt="" | ||
| 61 | loading="lazy" | ||
| 62 | className="size-8 shrink-0 rounded-md object-contain" | ||
| 63 | /> | ||
| 64 | <span className="min-w-0 flex-1"> | ||
| 65 | <span className="block truncate text-sm font-medium text-foreground"> | ||
| 66 | {props.item.name} | ||
| 67 | </span> | ||
| 68 | <span className="block truncate text-xs text-muted-foreground"> | ||
| 69 | {props.item.description} | ||
| 70 | </span> | ||
| 71 | </span> | ||
| 72 | </button> | ||
| 73 | <AddButton added={props.added} onAdd={props.onAdd} /> | ||
| 74 | </div> | ||
| 75 | ); | ||
| 76 | } | ||
| 77 | |||
| 78 | /** Offered whenever the query cannot be satisfied from the registry — either | ||
| 79 | * because it looks like a URL, or because nothing matched. */ | ||
| 80 | function CustomPrompt(props: { readonly query: string; readonly onAddCustom: () => void }) { | ||
| 81 | const looksLikeUrl = /^[a-z]+:\/\//i.test(props.query) || props.query.includes("/"); | ||
| 82 | return ( | ||
| 83 | <div className="flex items-center gap-3 rounded-lg border border-dashed border-border px-3 py-3"> | ||
| 84 | <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"> | ||
| 85 | <PlusIcon className="size-4" aria-hidden /> | ||
| 86 | </span> | ||
| 87 | <div className="min-w-0 flex-1"> | ||
| 88 | <p className="text-sm font-medium text-foreground"> | ||
| 89 | {looksLikeUrl ? "Add this URL directly" : "Not in the registry?"} | ||
| 90 | </p> | ||
| 91 | <p className="text-xs text-muted-foreground"> | ||
| 92 | Point executor at any MCP server, OpenAPI spec, or GraphQL endpoint. | ||
| 93 | </p> | ||
| 94 | </div> | ||
| 95 | <Button type="button" size="sm" className="shrink-0" onClick={props.onAddCustom}> | ||
| 96 | Add custom | ||
| 97 | </Button> | ||
| 98 | </div> | ||
| 99 | ); | ||
| 100 | } | ||
| 101 | |||
| 102 | export function BrowsePage(props: { | ||
| 103 | readonly addedDomains: readonly string[]; | ||
| 104 | readonly onAdd: (item: CatalogItem) => void; | ||
| 105 | readonly onOpen: (item: CatalogItem) => void; | ||
| 106 | readonly onAddCustom: () => void; | ||
| 107 | }) { | ||
| 108 | const [items, setItems] = useState<readonly CatalogItem[] | null>(null); | ||
| 109 | const [query, setQuery] = useState(""); | ||
| 110 | const [category, setCategory] = useState<string>("All"); | ||
| 111 | |||
| 112 | useEffect(() => { | ||
| 113 | let live = true; | ||
| 114 | void loadCatalog().then((loaded) => { | ||
| 115 | if (live) setItems(loaded); | ||
| 116 | }); | ||
| 117 | return () => { | ||
| 118 | live = false; | ||
| 119 | }; | ||
| 120 | }, []); | ||
| 121 | |||
| 122 | const searched = useMemo(() => (items ? searchItems(items, query) : []), [items, query]); | ||
| 123 | const searching = query.trim().length > 0; | ||
| 124 | |||
| 125 | const sections = useMemo(() => { | ||
| 126 | if (searching) return []; | ||
| 127 | const wanted = category === "All" ? CATEGORY_ORDER : [category]; | ||
| 128 | return wanted | ||
| 129 | .map((name) => ({ | ||
| 130 | name, | ||
| 131 | items: searched.filter((item) => item.category === name).slice(0, SECTION_SIZE), | ||
| 132 | })) | ||
| 133 | .filter((section) => section.items.length > 0); | ||
| 134 | }, [searched, category, searching]); | ||
| 135 | |||
| 136 | return ( | ||
| 137 | <> | ||
| 138 | <div className="mb-4 flex gap-2"> | ||
| 139 | <div className="relative min-w-0 flex-1"> | ||
| 140 | <SearchIcon | ||
| 141 | className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" | ||
| 142 | aria-hidden | ||
| 143 | /> | ||
| 144 | <Input | ||
| 145 | value={query} | ||
| 146 | onChange={(event) => setQuery((event.target as HTMLInputElement).value)} | ||
| 147 | placeholder="Search integrations…" | ||
| 148 | aria-label="Search integrations" | ||
| 149 | className="h-11 pl-9 text-sm" | ||
| 150 | /> | ||
| 151 | </div> | ||
| 152 | <Button | ||
| 153 | type="button" | ||
| 154 | variant="outline" | ||
| 155 | className="h-11 shrink-0 gap-1.5" | ||
| 156 | onClick={props.onAddCustom} | ||
| 157 | > | ||
| 158 | <PlusIcon className="size-4" aria-hidden /> | ||
| 159 | Add custom | ||
| 160 | </Button> | ||
| 161 | </div> | ||
| 162 | |||
| 163 | <div className="mb-8 flex flex-wrap gap-1.5"> | ||
| 164 | {["All", ...CATEGORY_ORDER].map((name) => ( | ||
| 165 | // oxlint-disable-next-line react/forbid-elements | ||
| 166 | <button | ||
| 167 | key={name} | ||
| 168 | type="button" | ||
| 169 | onClick={() => setCategory(name)} | ||
| 170 | className={cn( | ||
| 171 | "rounded-full border px-3 py-1 text-xs font-medium transition-colors", | ||
| 172 | category === name | ||
| 173 | ? "border-foreground/20 bg-foreground text-background" | ||
| 174 | : "border-border text-muted-foreground hover:bg-accent hover:text-foreground", | ||
| 175 | )} | ||
| 176 | > | ||
| 177 | {name} | ||
| 178 | </button> | ||
| 179 | ))} | ||
| 180 | </div> | ||
| 181 | |||
| 182 | {items === null ? ( | ||
| 183 | <div className="flex flex-col gap-2"> | ||
| 184 | {Array.from({ length: 8 }).map((_, i) => ( | ||
| 185 | <div key={i} className="flex items-center gap-3 px-3 py-2.5"> | ||
| 186 | <Skeleton className="size-8 shrink-0 rounded-md" /> | ||
| 187 | <div className="flex min-w-0 flex-1 flex-col gap-1.5"> | ||
| 188 | <Skeleton className="h-3.5" style={{ width: `${20 + ((i * 11) % 20)}%` }} /> | ||
| 189 | <Skeleton className="h-3" style={{ width: `${45 + ((i * 13) % 30)}%` }} /> | ||
| 190 | </div> | ||
| 191 | <Skeleton className="h-8 w-14 rounded-md" /> | ||
| 192 | </div> | ||
| 193 | ))} | ||
| 194 | </div> | ||
| 195 | ) : searching ? ( | ||
| 196 | <div className="flex flex-col gap-1"> | ||
| 197 | {searched.slice(0, 40).map((item) => ( | ||
| 198 | <CatalogRow | ||
| 199 | key={item.domain} | ||
| 200 | item={item} | ||
| 201 | added={props.addedDomains.includes(item.domain)} | ||
| 202 | onAdd={() => props.onAdd(item)} | ||
| 203 | onOpen={() => props.onOpen(item)} | ||
| 204 | /> | ||
| 205 | ))} | ||
| 206 | <div className="mt-2"> | ||
| 207 | <CustomPrompt query={query} onAddCustom={props.onAddCustom} /> | ||
| 208 | </div> | ||
| 209 | </div> | ||
| 210 | ) : ( | ||
| 211 | <div className="flex flex-col gap-8"> | ||
| 212 | {sections.map((section) => ( | ||
| 213 | <section key={section.name}> | ||
| 214 | <h2 className="mb-2 px-3 text-xs font-medium uppercase tracking-widest text-muted-foreground"> | ||
| 215 | {section.name} | ||
| 216 | </h2> | ||
| 217 | <div className="flex flex-col gap-1"> | ||
| 218 | {section.items.map((item) => ( | ||
| 219 | <CatalogRow | ||
| 220 | key={item.domain} | ||
| 221 | item={item} | ||
| 222 | added={props.addedDomains.includes(item.domain)} | ||
| 223 | onAdd={() => props.onAdd(item)} | ||
| 224 | onOpen={() => props.onOpen(item)} | ||
| 225 | /> | ||
| 226 | ))} | ||
| 227 | </div> | ||
| 228 | </section> | ||
| 229 | ))} | ||
| 230 | </div> | ||
| 231 | )} | ||
| 232 | </> | ||
| 233 | ); | ||
| 234 | } | ||
| @@ -0,0 +1,242 @@ | |||
| 1 | // Reworked: adding something the registry has never heard of. | ||
| 2 | // | ||
| 3 | // This is the case the picker cannot cover and executor exists for — an | ||
| 4 | // internal API, a private MCP server, a spec behind a VPN. It has to be a | ||
| 5 | // first-class door, not the fallback you reach after search disappoints. | ||
| 6 | // | ||
| 7 | // The rule that keeps it simple: paste one URL, we say what we found, you add | ||
| 8 | // it. What comes out the other side is an ordinary added integration with an | ||
| 9 | // ordinary "Needs auth" account — identical to a registry pick from that point | ||
| 10 | // on, because the only thing the registry was ever providing is the definition. | ||
| 11 | |||
| 12 | import { useState } from "react"; | ||
| 13 | import { ArrowUpRightIcon } from "lucide-react"; | ||
| 14 | import { Button } from "@executor-js/react/components/button"; | ||
| 15 | import { Label } from "@executor-js/react/components/label"; | ||
| 16 | import { | ||
| 17 | Dialog, | ||
| 18 | DialogContent, | ||
| 19 | DialogDescription, | ||
| 20 | DialogFooter, | ||
| 21 | DialogHeader, | ||
| 22 | DialogTitle, | ||
| 23 | } from "@executor-js/react/components/dialog"; | ||
| 24 | import { getDomain } from "tldts"; | ||
| 25 | import { logoFor, type AddedIntegration } from "../../catalog"; | ||
| 26 | import { INTEGRATIONS_SH_ORIGIN } from "../../fixtures"; | ||
| 27 | |||
| 28 | type Kind = "mcp" | "openapi" | "graphql"; | ||
| 29 | |||
| 30 | const KIND_LABEL: Record<Kind, string> = { | ||
| 31 | mcp: "MCP server", | ||
| 32 | openapi: "OpenAPI spec", | ||
| 33 | graphql: "GraphQL endpoint", | ||
| 34 | }; | ||
| 35 | |||
| 36 | interface Detected { | ||
| 37 | readonly kind: Kind; | ||
| 38 | readonly domain: string; | ||
| 39 | readonly url: string; | ||
| 40 | /** A registry entry related to this URL, if there is one. `exact` separates | ||
| 41 | * two genuinely different situations that a string match cannot tell apart | ||
| 42 | * on its own: the host IS the registry entry, versus the host merely sits | ||
| 43 | * under a domain the registry knows. `mcp.posthog.com` under `posthog.com` | ||
| 44 | * is probably the same service; `internal.acme.com` under `acme.com` is | ||
| 45 | * probably not, so the copy must not claim it is. */ | ||
| 46 | readonly registryDomain: string | null; | ||
| 47 | readonly exact: boolean; | ||
| 48 | } | ||
| 49 | |||
| 50 | const guessKind = (url: URL): Kind => { | ||
| 51 | const path = `${url.pathname}${url.search}`.toLowerCase(); | ||
| 52 | if (path.includes("graphql")) return "graphql"; | ||
| 53 | if (/\/mcp\b|\/sse\b/.test(path)) return "mcp"; | ||
| 54 | if (/openapi|swagger|\.ya?ml$|\.json$/.test(path)) return "openapi"; | ||
| 55 | // A bare origin is most often an MCP server these days; the detect step in | ||
| 56 | // the real product probes rather than guesses. | ||
| 57 | return path === "/" || path.length === 0 ? "mcp" : "openapi"; | ||
| 58 | }; | ||
| 59 | |||
| 60 | const detect = async (raw: string): Promise<Detected | { readonly error: string }> => { | ||
| 61 | const trimmed = raw.trim(); | ||
| 62 | const withScheme = /^[a-z][a-z0-9+\-.]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; | ||
| 63 | const parsed = ((): URL | null => { | ||
| 64 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL parsing reports failure by throwing | ||
| 65 | try { | ||
| 66 | return new URL(withScheme); | ||
| 67 | } catch { | ||
| 68 | return null; | ||
| 69 | } | ||
| 70 | })(); | ||
| 71 | if (!parsed) return { error: "That doesn't look like a URL." }; | ||
| 72 | |||
| 73 | const host = parsed.hostname.replace(/^www\./, ""); | ||
| 74 | // The registry keys on the registrable domain, so an endpoint living on a | ||
| 75 | // subdomain (`mcp.posthog.com`) only matches once we also try its parent. | ||
| 76 | const candidates = [...new Set([host, getDomain(host)])].filter( | ||
| 77 | (candidate): candidate is string => candidate !== null && candidate.length > 0, | ||
| 78 | ); | ||
| 79 | const registryDomain = await (async () => { | ||
| 80 | for (const candidate of candidates) { | ||
| 81 | // A miss and a network failure mean the same thing here — nothing extra | ||
| 82 | // to offer — so both just move on to the next candidate. | ||
| 83 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch reports transport failure by rejecting | ||
| 84 | try { | ||
| 85 | const response = await fetch( | ||
| 86 | `${INTEGRATIONS_SH_ORIGIN}/api/${encodeURIComponent(candidate)}/surface`, | ||
| 87 | ); | ||
| 88 | if (response.ok) return candidate; | ||
| 89 | } catch { | ||
| 90 | continue; | ||
| 91 | } | ||
| 92 | } | ||
| 93 | return null; | ||
| 94 | })(); | ||
| 95 | |||
| 96 | return { | ||
| 97 | kind: guessKind(parsed), | ||
| 98 | domain: host, | ||
| 99 | url: parsed.toString(), | ||
| 100 | registryDomain, | ||
| 101 | exact: registryDomain === host, | ||
| 102 | }; | ||
| 103 | }; | ||
| 104 | |||
| 105 | export function AddCustomDialog(props: { | ||
| 106 | readonly open: boolean; | ||
| 107 | readonly onOpenChange: (open: boolean) => void; | ||
| 108 | readonly onAdd: (integration: AddedIntegration) => void; | ||
| 109 | }) { | ||
| 110 | const [raw, setRaw] = useState(""); | ||
| 111 | const [busy, setBusy] = useState(false); | ||
| 112 | const [result, setResult] = useState<Detected | null>(null); | ||
| 113 | const [error, setError] = useState<string | null>(null); | ||
| 114 | |||
| 115 | const run = async () => { | ||
| 116 | setBusy(true); | ||
| 117 | setError(null); | ||
| 118 | const outcome = await detect(raw); | ||
| 119 | setBusy(false); | ||
| 120 | if ("error" in outcome) { | ||
| 121 | setError(outcome.error); | ||
| 122 | setResult(null); | ||
| 123 | return; | ||
| 124 | } | ||
| 125 | setResult(outcome); | ||
| 126 | }; | ||
| 127 | |||
| 128 | return ( | ||
| 129 | <Dialog open={props.open} onOpenChange={props.onOpenChange}> | ||
| 130 | <DialogContent className="sm:max-w-lg"> | ||
| 131 | <DialogHeader> | ||
| 132 | <DialogTitle>Add a custom integration</DialogTitle> | ||
| 133 | <DialogDescription> | ||
| 134 | Point at an MCP server, an OpenAPI spec, or a GraphQL endpoint. It does not need to be | ||
| 135 | in the registry. | ||
| 136 | </DialogDescription> | ||
| 137 | </DialogHeader> | ||
| 138 | |||
| 139 | <div className="flex flex-col gap-4"> | ||
| 140 | <div className="space-y-2"> | ||
| 141 | <Label | ||
| 142 | htmlFor="custom-url" | ||
| 143 | className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground" | ||
| 144 | > | ||
| 145 | URL | ||
| 146 | </Label> | ||
| 147 | <div className="flex gap-2"> | ||
| 148 | {/* oxlint-disable-next-line react/forbid-elements */} | ||
| 149 | <input | ||
| 150 | id="custom-url" | ||
| 151 | // oxlint-disable-next-line jsx_a11y/no-autofocus -- the only field on the screen | ||
| 152 | autoFocus | ||
| 153 | value={raw} | ||
| 154 | onChange={(event) => { | ||
| 155 | setRaw(event.target.value); | ||
| 156 | setResult(null); | ||
| 157 | setError(null); | ||
| 158 | }} | ||
| 159 | onKeyDown={(event) => { | ||
| 160 | if (event.key === "Enter" && raw.trim().length > 0) void run(); | ||
| 161 | }} | ||
| 162 | placeholder="https://internal.acme.com/mcp" | ||
| 163 | className="h-10 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 font-mono text-sm outline-none transition-colors focus:border-ring dark:bg-input/30" | ||
| 164 | /> | ||
| 165 | <Button | ||
| 166 | type="button" | ||
| 167 | variant="outline" | ||
| 168 | disabled={raw.trim().length === 0 || busy} | ||
| 169 | loading={busy} | ||
| 170 | onClick={() => void run()} | ||
| 171 | > | ||
| 172 | Check | ||
| 173 | </Button> | ||
| 174 | </div> | ||
| 175 | {error ? <p className="text-xs text-destructive">{error}</p> : null} | ||
| 176 | </div> | ||
| 177 | |||
| 178 | {result ? ( | ||
| 179 | <div className="space-y-2 rounded-lg border border-border bg-muted/20 px-3 py-3"> | ||
| 180 | <div className="flex items-center gap-2.5"> | ||
| 181 | <img | ||
| 182 | src={logoFor(result.domain)} | ||
| 183 | alt="" | ||
| 184 | className="size-6 shrink-0 rounded object-contain" | ||
| 185 | /> | ||
| 186 | <div className="min-w-0 flex-1"> | ||
| 187 | <p className="truncate text-sm font-medium text-foreground">{result.domain}</p> | ||
| 188 | <p className="text-xs text-muted-foreground">{KIND_LABEL[result.kind]}</p> | ||
| 189 | </div> | ||
| 190 | </div> | ||
| 191 | {result.registryDomain ? ( | ||
| 192 | <p className="text-xs leading-relaxed text-muted-foreground"> | ||
| 193 | {result.exact | ||
| 194 | ? `The registry already covers ${result.registryDomain}. Adding it from search brings the credential setup instructions with it — this URL will be added as-is, with none of that.` | ||
| 195 | : `The registry has an entry for ${result.registryDomain}, which may or may not be the same service as this host. This URL will be added as-is either way.`}{" "} | ||
| 196 | <a | ||
| 197 | href={`${INTEGRATIONS_SH_ORIGIN}/${result.registryDomain}/`} | ||
| 198 | target="_blank" | ||
| 199 | rel="noreferrer" | ||
| 200 | className="inline-flex items-center gap-0.5 font-medium text-foreground underline underline-offset-2" | ||
| 201 | > | ||
| 202 | See the entry | ||
| 203 | <ArrowUpRightIcon className="size-3" aria-hidden /> | ||
| 204 | </a> | ||
| 205 | </p> | ||
| 206 | ) : ( | ||
| 207 | <p className="text-xs leading-relaxed text-muted-foreground"> | ||
| 208 | Not in the registry. You'll set up its credential yourself on the next | ||
| 209 | screen. | ||
| 210 | </p> | ||
| 211 | )} | ||
| 212 | </div> | ||
| 213 | ) : null} | ||
| 214 | </div> | ||
| 215 | |||
| 216 | <DialogFooter> | ||
| 217 | <Button type="button" variant="ghost" onClick={() => props.onOpenChange(false)}> | ||
| 218 | Cancel | ||
| 219 | </Button> | ||
| 220 | <Button | ||
| 221 | type="button" | ||
| 222 | disabled={result === null} | ||
| 223 | onClick={() => { | ||
| 224 | if (!result) return; | ||
| 225 | props.onAdd({ | ||
| 226 | domain: result.domain, | ||
| 227 | name: result.domain, | ||
| 228 | description: `${KIND_LABEL[result.kind]} at ${result.url}`, | ||
| 229 | icon: logoFor(result.domain), | ||
| 230 | formats: [result.kind], | ||
| 231 | source: "custom", | ||
| 232 | url: result.url, | ||
| 233 | }); | ||
| 234 | }} | ||
| 235 | > | ||
| 236 | Add integration | ||
| 237 | </Button> | ||
| 238 | </DialogFooter> | ||
| 239 | </DialogContent> | ||
| 240 | </Dialog> | ||
| 241 | ); | ||
| 242 | } | ||
| @@ -0,0 +1,209 @@ | |||
| 1 | // Reworked: one added integration. | ||
| 2 | // | ||
| 3 | // "Integration" and "connection" still exist — an integration can hold several | ||
| 4 | // accounts, which is a real capability — but the hierarchy is no longer | ||
| 5 | // something you have to understand before you can start. An add always leaves | ||
| 6 | // exactly one account row behind, already named, in the one state that matters: | ||
| 7 | // Needs auth, with the button that fixes it. Tools stay collapsed until there | ||
| 8 | // are some. | ||
| 9 | // | ||
| 10 | // A custom integration renders through this same screen. The only difference it | ||
| 11 | // is allowed to make is the one line naming where its definition came from. | ||
| 12 | |||
| 13 | import { ArrowLeftIcon, ArrowUpRightIcon, ChevronDownIcon, PlusIcon } from "lucide-react"; | ||
| 14 | import { Button } from "@executor-js/react/components/button"; | ||
| 15 | import { cn } from "@executor-js/react/lib/utils"; | ||
| 16 | import type { AddedIntegration } from "../../catalog"; | ||
| 17 | |||
| 18 | export interface DemoAccount { | ||
| 19 | readonly label: string; | ||
| 20 | readonly status: "needs-auth" | "connected"; | ||
| 21 | readonly identity?: string; | ||
| 22 | } | ||
| 23 | |||
| 24 | function AccountRow(props: { readonly account: DemoAccount; readonly onAuthenticate: () => void }) { | ||
| 25 | const needsAuth = props.account.status === "needs-auth"; | ||
| 26 | return ( | ||
| 27 | <div className="flex items-center gap-3 px-3 py-2.5"> | ||
| 28 | <span | ||
| 29 | className={cn( | ||
| 30 | "size-2 shrink-0 rounded-full", | ||
| 31 | needsAuth ? "bg-amber-500" : "bg-emerald-500", | ||
| 32 | )} | ||
| 33 | aria-hidden | ||
| 34 | /> | ||
| 35 | <span className="min-w-0 flex-1"> | ||
| 36 | <span className="block truncate text-sm text-foreground"> | ||
| 37 | {props.account.identity ?? props.account.label} | ||
| 38 | </span> | ||
| 39 | </span> | ||
| 40 | {needsAuth ? ( | ||
| 41 | <> | ||
| 42 | <span className="shrink-0 text-xs font-medium text-amber-500">Needs auth</span> | ||
| 43 | <Button type="button" variant="outline" size="sm" onClick={props.onAuthenticate}> | ||
| 44 | Authenticate | ||
| 45 | </Button> | ||
| 46 | </> | ||
| 47 | ) : ( | ||
| 48 | <span className="shrink-0 text-xs text-muted-foreground">Connected</span> | ||
| 49 | )} | ||
| 50 | </div> | ||
| 51 | ); | ||
| 52 | } | ||
| 53 | |||
| 54 | export function NextIntegrationDetail(props: { | ||
| 55 | readonly item: AddedIntegration; | ||
| 56 | readonly accounts: readonly DemoAccount[]; | ||
| 57 | readonly toolCount: number; | ||
| 58 | readonly onBack: () => void; | ||
| 59 | readonly onAuthenticate: (accountLabel: string) => void; | ||
| 60 | readonly onAddAccount: () => void; | ||
| 61 | readonly onRemove: () => void; | ||
| 62 | }) { | ||
| 63 | const custom = props.item.source === "custom"; | ||
| 64 | return ( | ||
| 65 | <> | ||
| 66 | <Button | ||
| 67 | type="button" | ||
| 68 | variant="ghost" | ||
| 69 | size="sm" | ||
| 70 | className="-ml-2 mb-6 gap-1.5 text-muted-foreground" | ||
| 71 | onClick={props.onBack} | ||
| 72 | > | ||
| 73 | <ArrowLeftIcon className="size-4" /> | ||
| 74 | All integrations | ||
| 75 | </Button> | ||
| 76 | |||
| 77 | <div className="mb-6 flex items-start gap-4"> | ||
| 78 | <img src={props.item.icon} alt="" className="size-12 shrink-0 rounded-xl object-contain" /> | ||
| 79 | <div className="min-w-0 flex-1"> | ||
| 80 | <h1 className="text-lg font-semibold text-foreground">{props.item.name}</h1> | ||
| 81 | {custom ? ( | ||
| 82 | <p className="mt-0.5 truncate font-mono text-xs text-muted-foreground"> | ||
| 83 | {props.item.url} | ||
| 84 | </p> | ||
| 85 | ) : ( | ||
| 86 | <a | ||
| 87 | href={`https://integrations.sh/${props.item.domain}/`} | ||
| 88 | target="_blank" | ||
| 89 | rel="noreferrer" | ||
| 90 | className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground" | ||
| 91 | > | ||
| 92 | {props.item.domain} | ||
| 93 | <ArrowUpRightIcon className="size-3" aria-hidden /> | ||
| 94 | </a> | ||
| 95 | )} | ||
| 96 | </div> | ||
| 97 | <Button type="button" variant="outline" size="sm" onClick={props.onRemove}> | ||
| 98 | Remove | ||
| 99 | </Button> | ||
| 100 | </div> | ||
| 101 | |||
| 102 | <p className="mb-8 text-sm leading-relaxed text-muted-foreground">{props.item.description}</p> | ||
| 103 | |||
| 104 | <section className="mb-6"> | ||
| 105 | <h2 className="mb-2 text-xs font-medium uppercase tracking-widest text-muted-foreground"> | ||
| 106 | Accounts | ||
| 107 | </h2> | ||
| 108 | <div className="overflow-hidden rounded-lg border border-border"> | ||
| 109 | <div className="divide-y divide-border"> | ||
| 110 | {props.accounts.map((account) => ( | ||
| 111 | <AccountRow | ||
| 112 | key={account.label} | ||
| 113 | account={account} | ||
| 114 | onAuthenticate={() => props.onAuthenticate(account.label)} | ||
| 115 | /> | ||
| 116 | ))} | ||
| 117 | </div> | ||
| 118 | {/* oxlint-disable-next-line react/forbid-elements */} | ||
| 119 | <button | ||
| 120 | type="button" | ||
| 121 | onClick={props.onAddAccount} | ||
| 122 | className="flex w-full items-center gap-1.5 border-t border-border px-3 py-2.5 text-left text-xs font-medium text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground" | ||
| 123 | > | ||
| 124 | <PlusIcon className="size-3.5" aria-hidden /> | ||
| 125 | Add another account | ||
| 126 | </button> | ||
| 127 | </div> | ||
| 128 | </section> | ||
| 129 | |||
| 130 | <section> | ||
| 131 | <div className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5"> | ||
| 132 | <span className="text-sm text-foreground"> | ||
| 133 | {props.toolCount === 0 | ||
| 134 | ? "Tools appear once an account is connected" | ||
| 135 | : `${props.toolCount} tools`} | ||
| 136 | </span> | ||
| 137 | <ChevronDownIcon className="size-4 text-muted-foreground" aria-hidden /> | ||
| 138 | </div> | ||
| 139 | </section> | ||
| 140 | </> | ||
| 141 | ); | ||
| 142 | } | ||
| 143 | |||
| 144 | /** The installed list. Without a sidebar this is how you get back to something | ||
| 145 | * you added, so it carries the same status vocabulary as the detail screen. */ | ||
| 146 | export function InstalledList(props: { | ||
| 147 | readonly items: readonly { | ||
| 148 | readonly item: AddedIntegration; | ||
| 149 | readonly accounts: readonly DemoAccount[]; | ||
| 150 | }[]; | ||
| 151 | readonly onOpen: (domain: string) => void; | ||
| 152 | readonly onBrowse: () => void; | ||
| 153 | }) { | ||
| 154 | if (props.items.length === 0) { | ||
| 155 | return ( | ||
| 156 | <div className="rounded-lg border border-dashed border-border py-16 text-center"> | ||
| 157 | <p className="text-sm font-medium text-foreground">Nothing added yet</p> | ||
| 158 | <p className="mt-1 text-sm text-muted-foreground"> | ||
| 159 | Add an integration and it shows up here. | ||
| 160 | </p> | ||
| 161 | <Button type="button" size="sm" className="mt-4" onClick={props.onBrowse}> | ||
| 162 | Browse integrations | ||
| 163 | </Button> | ||
| 164 | </div> | ||
| 165 | ); | ||
| 166 | } | ||
| 167 | |||
| 168 | return ( | ||
| 169 | <div className="flex flex-col gap-1"> | ||
| 170 | {props.items.map(({ item, accounts }) => { | ||
| 171 | const pending = accounts.filter((account) => account.status === "needs-auth").length; | ||
| 172 | return ( | ||
| 173 | // oxlint-disable-next-line react/forbid-elements | ||
| 174 | <button | ||
| 175 | key={item.domain} | ||
| 176 | type="button" | ||
| 177 | onClick={() => props.onOpen(item.domain)} | ||
| 178 | className="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors hover:bg-accent/40" | ||
| 179 | > | ||
| 180 | <img | ||
| 181 | src={item.icon} | ||
| 182 | alt="" | ||
| 183 | loading="lazy" | ||
| 184 | className="size-8 shrink-0 rounded-md object-contain" | ||
| 185 | /> | ||
| 186 | <span className="min-w-0 flex-1"> | ||
| 187 | <span className="flex items-center gap-2"> | ||
| 188 | <span className="truncate text-sm font-medium text-foreground">{item.name}</span> | ||
| 189 | {item.source === "custom" ? ( | ||
| 190 | <span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-muted-foreground"> | ||
| 191 | Custom | ||
| 192 | </span> | ||
| 193 | ) : null} | ||
| 194 | </span> | ||
| 195 | <span className="block truncate text-xs text-muted-foreground"> | ||
| 196 | {accounts.length} account{accounts.length === 1 ? "" : "s"} | ||
| 197 | </span> | ||
| 198 | </span> | ||
| 199 | {pending > 0 ? ( | ||
| 200 | <span className="shrink-0 text-xs font-medium text-amber-500">Needs auth</span> | ||
| 201 | ) : ( | ||
| 202 | <span className="shrink-0 text-xs text-muted-foreground">Connected</span> | ||
| 203 | )} | ||
| 204 | </button> | ||
| 205 | ); | ||
| 206 | })} | ||
| 207 | </div> | ||
| 208 | ); | ||
| 209 | } | ||
| @@ -0,0 +1,97 @@ | |||
| 1 | // Reproduction of the console chrome (packages/app/src/web/shell.tsx): the | ||
| 2 | // wordmark bar, the five nav items, the second INTEGRATIONS heading that | ||
| 3 | // repeats the first nav item, and the four-link footer. | ||
| 4 | |||
| 5 | import type { ReactNode } from "react"; | ||
| 6 | import { cn } from "@executor-js/react/lib/utils"; | ||
| 7 | import type { DemoIntegration } from "./fixtures"; | ||
| 8 | |||
| 9 | const NAV_ITEMS = ["Integrations", "Secrets", "Policies", "Toolkits", "Artifacts"] as const; | ||
| 10 | |||
| 11 | function NavItem(props: { readonly label: string; readonly active: boolean }) { | ||
| 12 | return ( | ||
| 13 | <span | ||
| 14 | className={cn( | ||
| 15 | "flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors", | ||
| 16 | props.active | ||
| 17 | ? "bg-sidebar-active font-medium text-foreground" | ||
| 18 | : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", | ||
| 19 | )} | ||
| 20 | > | ||
| 21 | {props.label} | ||
| 22 | </span> | ||
| 23 | ); | ||
| 24 | } | ||
| 25 | |||
| 26 | export function Shell(props: { | ||
| 27 | readonly integrations: readonly DemoIntegration[]; | ||
| 28 | readonly children: ReactNode; | ||
| 29 | }) { | ||
| 30 | return ( | ||
| 31 | <div className="flex h-full min-h-0 w-full"> | ||
| 32 | <aside className="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar"> | ||
| 33 | <div className="flex h-12 shrink-0 items-center gap-2 border-b border-sidebar-border px-4"> | ||
| 34 | <span className="font-mono text-sm font-medium tracking-tight text-foreground"> | ||
| 35 | executor | ||
| 36 | </span> | ||
| 37 | <span className="ml-auto truncate font-mono text-[11px] text-muted-foreground"> | ||
| 38 | theo's org | ||
| 39 | </span> | ||
| 40 | </div> | ||
| 41 | |||
| 42 | <nav className="flex flex-1 flex-col overflow-y-auto p-2"> | ||
| 43 | {NAV_ITEMS.map((label) => ( | ||
| 44 | <NavItem key={label} label={label} active={label === "Integrations"} /> | ||
| 45 | ))} | ||
| 46 | |||
| 47 | <span className="mt-5 mb-1 px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground"> | ||
| 48 | Integrations | ||
| 49 | </span> | ||
| 50 | |||
| 51 | {props.integrations.length === 0 ? ( | ||
| 52 | <div className="px-2.5 py-2 text-sm leading-relaxed text-muted-foreground"> | ||
| 53 | No integrations yet | ||
| 54 | </div> | ||
| 55 | ) : ( | ||
| 56 | <div className="flex flex-col gap-px"> | ||
| 57 | {props.integrations.map((integration) => ( | ||
| 58 | <span | ||
| 59 | key={integration.slug} | ||
| 60 | className="group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-active/60 hover:text-foreground" | ||
| 61 | > | ||
| 62 | {integration.icon ? ( | ||
| 63 | <img | ||
| 64 | src={integration.icon} | ||
| 65 | alt="" | ||
| 66 | className="size-4 rounded-sm object-contain" | ||
| 67 | /> | ||
| 68 | ) : ( | ||
| 69 | <span className="size-4 rounded-sm bg-muted" /> | ||
| 70 | )} | ||
| 71 | <span className="flex-1 truncate">{integration.name}</span> | ||
| 72 | </span> | ||
| 73 | ))} | ||
| 74 | </div> | ||
| 75 | )} | ||
| 76 | </nav> | ||
| 77 | |||
| 78 | <div className="shrink-0 border-t border-sidebar-border px-4 py-2.5"> | ||
| 79 | <div className="flex flex-col gap-1.5 text-xs leading-none text-muted-foreground"> | ||
| 80 | <span className="flex items-center justify-between"> | ||
| 81 | <span>Commands</span> | ||
| 82 | <span className="font-mono text-[11px]">⌘K</span> | ||
| 83 | </span> | ||
| 84 | <span>Docs</span> | ||
| 85 | <span>Feedback / bug?</span> | ||
| 86 | <span>Star on GitHub</span> | ||
| 87 | <span className="mt-0.5 tabular-nums">v1.4.4</span> | ||
| 88 | </div> | ||
| 89 | </div> | ||
| 90 | </aside> | ||
| 91 | |||
| 92 | <main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"> | ||
| 93 | {props.children} | ||
| 94 | </main> | ||
| 95 | </div> | ||
| 96 | ); | ||
| 97 | } | ||
| @@ -0,0 +1,45 @@ | |||
| 1 | // Which screens carry which workspace state. Jumping straight to a late step | ||
| 2 | // has to bring that step's state with it, or the screen is not the one the | ||
| 3 | // step is about. | ||
| 4 | |||
| 5 | import type { StepId } from "./flow"; | ||
| 6 | import { gmailIntegration, posthogIntegration, type DemoIntegration } from "./fixtures"; | ||
| 7 | |||
| 8 | export type AccountModal = "none" | "credential" | "place" | "oauth-stuck"; | ||
| 9 | export type ScreenView = "integrations" | "add" | "detail"; | ||
| 10 | |||
| 11 | export interface StepState { | ||
| 12 | readonly integrations: readonly DemoIntegration[]; | ||
| 13 | readonly connectOpen: boolean; | ||
| 14 | readonly accountModal: AccountModal; | ||
| 15 | readonly view: ScreenView; | ||
| 16 | } | ||
| 17 | |||
| 18 | const EMPTY: StepState = { | ||
| 19 | integrations: [], | ||
| 20 | connectOpen: false, | ||
| 21 | accountModal: "none", | ||
| 22 | view: "integrations", | ||
| 23 | }; | ||
| 24 | |||
| 25 | const WITH_POSTHOG: StepState = { | ||
| 26 | integrations: [posthogIntegration], | ||
| 27 | connectOpen: false, | ||
| 28 | accountModal: "none", | ||
| 29 | view: "detail", | ||
| 30 | }; | ||
| 31 | |||
| 32 | export const stepState: Readonly<Record<StepId, StepState>> = { | ||
| 33 | "integrations-empty": EMPTY, | ||
| 34 | "connect-dialog": { ...EMPTY, connectOpen: true }, | ||
| 35 | "add-openapi": { ...EMPTY, view: "add" }, | ||
| 36 | "detail-accounts": WITH_POSTHOG, | ||
| 37 | "add-account-credential": { ...WITH_POSTHOG, accountModal: "credential" }, | ||
| 38 | "add-account-place": { ...WITH_POSTHOG, accountModal: "place" }, | ||
| 39 | "oauth-stuck": { | ||
| 40 | ...WITH_POSTHOG, | ||
| 41 | integrations: [gmailIntegration], | ||
| 42 | accountModal: "oauth-stuck", | ||
| 43 | }, | ||
| 44 | "integrations-populated": { ...WITH_POSTHOG, view: "integrations" }, | ||
| 45 | }; | ||
| @@ -0,0 +1,5 @@ | |||
| 1 | /* The console's real stylesheet — same tokens, same primitives. The extra | ||
| 2 | @source is this package's own sources, which the react package's globs | ||
| 3 | (rooted at packages/react/src) do not cover. */ | ||
| 4 | @import "@executor-js/react/globals.css"; | ||
| 5 | @source "./**/*.{ts,tsx}"; | ||
| @@ -0,0 +1,15 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ESNext", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "bundler", | ||
| 6 | "allowImportingTsExtensions": true, | ||
| 7 | "strict": true, | ||
| 8 | "esModuleInterop": true, | ||
| 9 | "skipLibCheck": true, | ||
| 10 | "noEmit": true, | ||
| 11 | "jsx": "react-jsx", | ||
| 12 | "types": ["vite/client"] | ||
| 13 | }, | ||
| 14 | "include": ["src", "vite.config.ts"] | ||
| 15 | } | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | import { defineConfig } from "vite"; | ||
| 2 | import react from "@vitejs/plugin-react"; | ||
| 3 | import tailwindcss from "@tailwindcss/vite"; | ||
| 4 | |||
| 5 | // Standalone SPA reproduction of the current add-integration flow. No executor | ||
| 6 | // API, no auth, no backend — every screen runs off `src/fixtures.ts` plus the | ||
| 7 | // live public integrations.sh catalog, so the flow can be redesigned without | ||
| 8 | // standing up a tenant. | ||
| 9 | export default defineConfig({ | ||
| 10 | plugins: [react(), tailwindcss()], | ||
| 11 | server: { port: 5199 }, | ||
| 12 | }); | ||
| @@ -1,5 +1,19 @@ | |||
| 1 | # @executor-js/plugin-desktop-settings | 1 | # @executor-js/plugin-desktop-settings |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/sdk@1.6.6 | ||
| 9 | |||
| 10 | ## 1.6.5 | ||
| 11 | |||
| 12 | ### Patch Changes | ||
| 13 | |||
| 14 | - Updated dependencies []: | ||
| 15 | - @executor-js/sdk@1.6.5 | ||
| 16 | |||
| 3 | ## 1.6.4 | 17 | ## 1.6.4 |
| 4 | 18 | ||
| 5 | ### Patch Changes | 19 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-desktop-settings", | 2 | "name": "@executor-js/plugin-desktop-settings", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/desktop-settings", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/desktop-settings", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -1,5 +1,19 @@ | |||
| 1 | # @executor-js/plugin-encrypted-secrets | 1 | # @executor-js/plugin-encrypted-secrets |
| 2 | 2 | ||
| 3 | ## 0.0.48 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/sdk@1.6.6 | ||
| 9 | |||
| 10 | ## 0.0.47 | ||
| 11 | |||
| 12 | ### Patch Changes | ||
| 13 | |||
| 14 | - Updated dependencies []: | ||
| 15 | - @executor-js/sdk@1.6.5 | ||
| 16 | |||
| 3 | ## 0.0.46 | 17 | ## 0.0.46 |
| 4 | 18 | ||
| 5 | ### Patch Changes | 19 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-encrypted-secrets", | 2 | "name": "@executor-js/plugin-encrypted-secrets", |
| 3 | "version": "0.0.46", | 3 | "version": "0.0.48", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "exports": { | 6 | "exports": { |
| @@ -1,5 +1,19 @@ | |||
| 1 | # @executor-js/plugin-example | 1 | # @executor-js/plugin-example |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/sdk@1.6.6 | ||
| 9 | |||
| 10 | ## 1.6.5 | ||
| 11 | |||
| 12 | ### Patch Changes | ||
| 13 | |||
| 14 | - Updated dependencies []: | ||
| 15 | - @executor-js/sdk@1.6.5 | ||
| 16 | |||
| 3 | ## 1.6.4 | 17 | ## 1.6.4 |
| 4 | 18 | ||
| 5 | ### Patch Changes | 19 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-example", | 2 | "name": "@executor-js/plugin-example", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/example", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/example", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -1,5 +1,19 @@ | |||
| 1 | # @executor-js/plugin-file-secrets | 1 | # @executor-js/plugin-file-secrets |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/sdk@1.6.6 | ||
| 9 | |||
| 10 | ## 1.6.5 | ||
| 11 | |||
| 12 | ### Patch Changes | ||
| 13 | |||
| 14 | - Updated dependencies []: | ||
| 15 | - @executor-js/sdk@1.6.5 | ||
| 16 | |||
| 3 | ## 1.6.4 | 17 | ## 1.6.4 |
| 4 | 18 | ||
| 5 | ### Patch Changes | 19 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-file-secrets", | 2 | "name": "@executor-js/plugin-file-secrets", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/file-secrets", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/file-secrets", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -1,5 +1,25 @@ | |||
| 1 | # @executor-js/plugin-graphql | 1 | # @executor-js/plugin-graphql |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: | ||
| 8 | - @executor-js/react@1.4.69 | ||
| 9 | - @executor-js/api@1.4.69 | ||
| 10 | - @executor-js/sdk@1.6.6 | ||
| 11 | - @executor-js/config@1.6.6 | ||
| 12 | |||
| 13 | ## 1.6.5 | ||
| 14 | |||
| 15 | ### Patch Changes | ||
| 16 | |||
| 17 | - Updated dependencies []: | ||
| 18 | - @executor-js/sdk@1.6.5 | ||
| 19 | - @executor-js/config@1.6.5 | ||
| 20 | - @executor-js/api@1.4.68 | ||
| 21 | - @executor-js/react@1.4.68 | ||
| 22 | |||
| 3 | ## 1.6.4 | 23 | ## 1.6.4 |
| 4 | 24 | ||
| 5 | ### Patch Changes | 25 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-graphql", | 2 | "name": "@executor-js/plugin-graphql", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/graphql", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/graphql", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | |||
| 15 | type AuthMethodRow, | 15 | type AuthMethodRow, |
| 16 | type AuthMethodSeed, | 16 | type AuthMethodSeed, |
| 17 | } from "@executor-js/react/components/auth-method-list-editor"; | 17 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 18 | import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | ||
| 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 19 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 19 | import { | 20 | import { |
| 20 | addIntegrationErrorMessage, | 21 | addIntegrationErrorMessage, |
| @@ -42,13 +43,25 @@ export default function AddGraphqlIntegration(props: { · 21 unmodified lines | |||
| 42 | onComplete: (slug?: string) => void; | 43 | onComplete: (slug?: string) => void; |
| 43 | onCancel: () => void; | 44 | onCancel: () => void; |
| 44 | initialUrl?: string; | 45 | initialUrl?: string; |
| 46 | initialAuthHeader?: string; | ||
| 47 | initialAuthNote?: string; | ||
| 45 | }) { | 48 | }) { |
| 46 | const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); | 49 | const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); |
| 47 | const [description, setDescription] = useState(""); | 50 | const [description, setDescription] = useState(""); |
| 48 | const identity = useIntegrationIdentity({ | 51 | const identity = useIntegrationIdentity({ |
| 49 | fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "", | 52 | fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "", |
| 50 | }); | 53 | }); |
| 51 | const authMethodList = useAuthMethodList(NO_SEEDS); | 54 | // GraphQL has no add-time detection, but the registry can declare the |
| 55 | // credential placement ("Authorization: {api_key}" — Linear's no-Bearer | ||
| 56 | // personal keys). A declared placement seeds the method list the same way a | ||
| 57 | // spec's security scheme would. | ||
| 58 | const registrySeeds = useMemo<readonly AuthMethodSeed[]>(() => { | ||
| 59 | const placement = props.initialAuthHeader | ||
| 60 | ? placementFromHeaderPattern(props.initialAuthHeader) | ||
| 61 | : null; | ||
| 62 | return placement ? [{ value: { kind: "apikey", placements: [placement] } }] : NO_SEEDS; | ||
| 63 | }, [props.initialAuthHeader]); | ||
| 64 | const authMethodList = useAuthMethodList(registrySeeds); | ||
| 52 | const [adding, setAdding] = useState(false); | 65 | const [adding, setAdding] = useState(false); |
| 53 | const [addError, setAddError] = useState<string | null>(null); | 66 | const [addError, setAddError] = useState<string | null>(null); |
| 54 | 67 | ||
| @@ -144,9 +157,13 @@ export default function AddGraphqlIntegration(props: { · 89 unmodified lines | |||
| 144 | list={authMethodList} | 157 | list={authMethodList} |
| 145 | allowedKinds={["none", "apikey"]} | 158 | allowedKinds={["none", "apikey"]} |
| 146 | emptyHint="No authentication declared. Add a method, or add the integration without auth and connect an account from the integration page later." | 159 | emptyHint="No authentication declared. Add a method, or add the integration without auth and connect an account from the integration page later." |
| 147 | footerHint="Every method here is registered with the integration. Connect an account from the integration page after adding." | 160 | footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page." |
| 148 | /> | 161 | /> |
| 149 | 162 | ||
| 163 | {props.initialAuthNote ? ( | ||
| 164 | <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p> | ||
| 165 | ) : null} | ||
| 166 | |||
| 150 | {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />} | 167 | {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />} |
| 151 | 168 | ||
| 152 | {addError && <FormErrorAlert message={addError} />} | 169 | {addError && <FormErrorAlert message={addError} />} |
| @@ -1,6 +1,49 @@ | |||
| 1 | import { lazy } from "react"; | 1 | import { lazy, useCallback } from "react"; |
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import type { | ||
| 5 | IntegrationPlugin, | ||
| 6 | IntegrationQuickAddInput, | ||
| 7 | IntegrationQuickAddResult, | ||
| 8 | } from "@executor-js/sdk/client"; | ||
| 9 | import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | ||
| 10 | import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; | ||
| 11 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 3 | import { graphqlPresets } from "../sdk/presets"; | 12 | import { graphqlPresets } from "../sdk/presets"; |
| 13 | import { createGraphqlIntegrationOptimistic } from "./atoms"; | ||
| 14 | import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config"; | ||
| 15 | |||
| 16 | /** One-click add for a registry GraphQL row. A GraphQL endpoint cannot | ||
| 17 | * describe its own auth, so the registry's declared header pattern is the | ||
| 18 | * only carrier of facts like Linear's no-Bearer key — it becomes the | ||
| 19 | * declared method, exactly as the add page would seed it. */ | ||
| 20 | function useGraphqlQuickAdd(): ( | ||
| 21 | input: IntegrationQuickAddInput, | ||
| 22 | ) => Promise<IntegrationQuickAddResult> { | ||
| 23 | const doAdd = useAtomSet(createGraphqlIntegrationOptimistic, { mode: "promiseExit" }); | ||
| 24 | return useCallback( | ||
| 25 | async (input) => { | ||
| 26 | const slug = slugifyNamespace(input.slug ?? input.name); | ||
| 27 | if (!slug) return { ok: false, reason: "no derivable slug" }; | ||
| 28 | const placement = input.authHeader ? placementFromHeaderPattern(input.authHeader) : null; | ||
| 29 | const authenticationTemplate = placement | ||
| 30 | ? graphqlAuthMethodInputsFromPlacements([placement]) | ||
| 31 | : []; | ||
| 32 | const exit = await doAdd({ | ||
| 33 | payload: { | ||
| 34 | endpoint: input.url, | ||
| 35 | slug, | ||
| 36 | name: input.name, | ||
| 37 | ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}), | ||
| 38 | }, | ||
| 39 | reactivityKeys: integrationWriteKeys, | ||
| 40 | }); | ||
| 41 | if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" }; | ||
| 42 | return { ok: true, slug: String(exit.value.slug) }; | ||
| 43 | }, | ||
| 44 | [doAdd], | ||
| 45 | ); | ||
| 46 | } | ||
| 4 | 47 | ||
| 5 | const importAdd = () => import("./AddGraphqlIntegration"); | 48 | const importAdd = () => import("./AddGraphqlIntegration"); |
| 6 | const importAccounts = () => import("./GraphqlAccountsPanel"); | 49 | const importAccounts = () => import("./GraphqlAccountsPanel"); |
| @@ -12,9 +55,10 @@ export const graphqlIntegrationPlugin: IntegrationPlugin = { · 5 unmodified lines | |||
| 12 | label: "GraphQL", | 55 | label: "GraphQL", |
| 13 | add: lazy(importAdd), | 56 | add: lazy(importAdd), |
| 14 | accounts: lazy(importAccounts), | 57 | accounts: lazy(importAccounts), |
| 15 | presets: graphqlPresets, | 58 | presets: graphqlPresets.map((preset) => ({ ...preset, registryListed: true })), |
| 16 | preload: () => { | 59 | preload: () => { |
| 17 | void importAdd(); | 60 | void importAdd(); |
| 18 | void importAccounts(); | 61 | void importAccounts(); |
| 19 | }, | 62 | }, |
| 63 | useQuickAdd: useGraphqlQuickAdd, | ||
| 20 | }; | 64 | }; |
| @@ -1,5 +1,19 @@ | |||
| 1 | # @executor-js/plugin-keychain | 1 | # @executor-js/plugin-keychain |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/sdk@1.6.6 | ||
| 9 | |||
| 10 | ## 1.6.5 | ||
| 11 | |||
| 12 | ### Patch Changes | ||
| 13 | |||
| 14 | - Updated dependencies []: | ||
| 15 | - @executor-js/sdk@1.6.5 | ||
| 16 | |||
| 3 | ## 1.6.4 | 17 | ## 1.6.4 |
| 4 | 18 | ||
| 5 | ### Patch Changes | 19 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-keychain", | 2 | "name": "@executor-js/plugin-keychain", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/keychain", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/keychain", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -1,5 +1,56 @@ | |||
| 1 | # @executor-js/plugin-mcp | 1 | # @executor-js/plugin-mcp |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - [#1869](https://github.com/UsefulSoftwareCo/executor/pull/1869) [`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Explain macOS permissions for Codex plugins instead of failing with an opaque | ||
| 8 | error. A refused grant used to surface as `Internal tool error [id]` — the | ||
| 9 | plugin reports "Unknown error" and only a numeric code says what happened, so | ||
| 10 | neither the user nor the model could tell that macOS was the blocker. | ||
| 11 | |||
| 12 | The bridge now recognises those codes and answers with the grant to enable and | ||
| 13 | where to find it. Each plugin's add screen also states what macOS will ask for | ||
| 14 | before anything runs, with a link straight to the right Privacy pane — macOS | ||
| 15 | asks once, and a dismissed prompt never returns. | ||
| 16 | |||
| 17 | The add screen checks that access when it opens, and holds the Add button | ||
| 18 | until the plugin answers. Adding one that macOS is still blocking produced an | ||
| 19 | integration that looked connected and failed on its first call, by which point | ||
| 20 | the screen explaining the fix was gone. | ||
| 21 | |||
| 22 | - Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: | ||
| 23 | - @executor-js/react@1.4.69 | ||
| 24 | - @executor-js/api@1.4.69 | ||
| 25 | - @executor-js/sdk@1.6.6 | ||
| 26 | - @executor-js/config@1.6.6 | ||
| 27 | |||
| 28 | ## 1.6.5 | ||
| 29 | |||
| 30 | ### Patch Changes | ||
| 31 | |||
| 32 | - [#1863](https://github.com/UsefulSoftwareCo/executor/pull/1863) [`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Adding a Codex plugin no longer asks for anything. `CODEX_HOME` is a path the | ||
| 33 | scanner already resolved, but it was passed on the channel that makes an | ||
| 34 | environment variable a credential — so the integration declared it as one, and | ||
| 35 | a person who reached the connect step was shown a masked field for a value | ||
| 36 | they should never have to know. | ||
| 37 | |||
| 38 | Stdio integrations can now carry non-secret environment as static | ||
| 39 | configuration, separate from declared secrets. The Codex plugins use it: they | ||
| 40 | declare no auth, and their connection is created for them. | ||
| 41 | |||
| 42 | - [#1861](https://github.com/UsefulSoftwareCo/executor/pull/1861) [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Stdio MCP servers are kept alive between tool calls** | ||
| 43 | |||
| 44 | Every tool call on a stdio MCP integration used to spawn a fresh child process, run the full MCP handshake, call the one tool, and tear the child down — roughly a second of overhead per call for an `npx`-launched server, on every call. Remote and app-server connections already reused sessions through the connection pool; plain stdio now joins them, with the same five-minute idle window, the same hashed identity key (command, args, cwd, secret env, credential values, owner and connection all separate identities), and the same drop-on-transport-failure semantics. This matches how MCP clients drive stdio servers generally: one long-lived child per session, not one per call. | ||
| 45 | |||
| 46 | A server that genuinely depends on fresh-process semantics can opt out with `spawnPerCall: true` in its stdio config (also accepted by the add-server API). The Codex app-server bridge ignores the opt-out — its approvals are session state, so it must pool. | ||
| 47 | |||
| 48 | - Updated dependencies []: | ||
| 49 | - @executor-js/sdk@1.6.5 | ||
| 50 | - @executor-js/config@1.6.5 | ||
| 51 | - @executor-js/api@1.4.68 | ||
| 52 | - @executor-js/react@1.4.68 | ||
| 53 | |||
| 3 | ## 1.6.4 | 54 | ## 1.6.4 |
| 4 | 55 | ||
| 5 | ### Patch Changes | 56 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-mcp", | 2 | "name": "@executor-js/plugin-mcp", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/mcp", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/mcp", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -36,6 +36,11 @@ const AddRemoteServerPayload = Schema.Struct({ · 35 unmodified lines | |||
| 36 | description: Schema.optional(Schema.String), | 36 | description: Schema.optional(Schema.String), |
| 37 | endpoint: Schema.String, | 37 | endpoint: Schema.String, |
| 38 | remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), | 38 | remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), |
| 39 | /** Pin legacy protocol negotiation for a server that echoes the modern | ||
| 40 | * revision but breaks its contract (the probe reports when only legacy | ||
| 41 | * worked). Omitting this here silently stripped the pin from the stored | ||
| 42 | * integration, leaving it unusable against exactly that server. */ | ||
| 43 | versionNegotiation: Schema.optional(Schema.Literals(["auto", "legacy"])), | ||
| 39 | slug: Schema.optional(Schema.String), | 44 | slug: Schema.optional(Schema.String), |
| 40 | queryParams: Schema.optional(StringMap), | 45 | queryParams: Schema.optional(StringMap), |
| 41 | headers: Schema.optional(StringMap), | 46 | headers: Schema.optional(StringMap), |
| @@ -58,6 +63,9 @@ const AddStdioServerPayload = Schema.Struct({ · 16 unmodified lines | |||
| 58 | envVars: Schema.optional(Schema.Array(Schema.String)), | 63 | envVars: Schema.optional(Schema.Array(Schema.String)), |
| 59 | /** One-shot secret env values (programmatic). The UI sends `envVars`. */ | 64 | /** One-shot secret env values (programmatic). The UI sends `envVars`. */ |
| 60 | env: Schema.optional(StringMap), | 65 | env: Schema.optional(StringMap), |
| 66 | /** Non-secret environment stored on the integration and injected at spawn. | ||
| 67 | * Unlike `env`, nothing here becomes a credential the user must type. */ | ||
| 68 | staticEnv: Schema.optional(StringMap), | ||
| 61 | cwd: Schema.optional(Schema.String), | 69 | cwd: Schema.optional(Schema.String), |
| 62 | /** Protocol negotiation at connect: `auto` probes `server/discover` (spec | 70 | /** Protocol negotiation at connect: `auto` probes `server/discover` (spec |
| 63 | * 2026-07-28) for modern-only servers; default is the legacy `initialize` | 71 | * 2026-07-28) for modern-only servers; default is the legacy `initialize` |
| @@ -73,6 +81,7 @@ const AddStdioServerPayload = Schema.Struct({ · 9 unmodified lines | |||
| 73 | server: Schema.String, | 81 | server: Schema.String, |
| 74 | surface: Schema.optional(Schema.Literals(["sky", "browser"])), | 82 | surface: Schema.optional(Schema.Literals(["sky", "browser"])), |
| 75 | modulePath: Schema.optional(Schema.String), | 83 | modulePath: Schema.optional(Schema.String), |
| 84 | presetId: Schema.optional(Schema.String), | ||
| 76 | }), | 85 | }), |
| 77 | ), | 86 | ), |
| 78 | slug: Schema.optional(Schema.String), | 87 | slug: Schema.optional(Schema.String), |
| @@ -97,6 +106,12 @@ const ProbeEndpointResponse = Schema.Struct({ · 18 unmodified lines | |||
| 97 | serverName: Schema.NullOr(Schema.String), | 106 | serverName: Schema.NullOr(Schema.String), |
| 98 | /** Server `instructions` from initialize — prefills the description field. */ | 107 | /** Server `instructions` from initialize — prefills the description field. */ |
| 99 | instructions: Schema.NullOr(Schema.String), | 108 | instructions: Schema.NullOr(Schema.String), |
| 109 | /** Which protocol negotiation worked, when discovery succeeded. `legacy` | ||
| 110 | * means the server echoes the modern revision but breaks its contract, and | ||
| 111 | * the add must pin `versionNegotiation: "legacy"` on the integration — | ||
| 112 | * omitting this field here silently stripped it from the HTTP response and | ||
| 113 | * the pin never happened. */ | ||
| 114 | versionNegotiation: Schema.optional(Schema.Literals(["auto", "legacy"])), | ||
| 100 | }); | 115 | }); |
| 101 | 116 | ||
| 102 | // --------------------------------------------------------------------------- | 117 | // --------------------------------------------------------------------------- |
| @@ -163,6 +178,7 @@ const CodexPluginEntrySchema = Schema.Struct({ · 60 unmodified lines | |||
| 163 | server: Schema.String, | 178 | server: Schema.String, |
| 164 | surface: Schema.optional(Schema.Literals(["sky", "browser"])), | 179 | surface: Schema.optional(Schema.Literals(["sky", "browser"])), |
| 165 | modulePath: Schema.optional(Schema.String), | 180 | modulePath: Schema.optional(Schema.String), |
| 181 | presetId: Schema.optional(Schema.String), | ||
| 166 | }), | 182 | }), |
| 167 | ), | 183 | ), |
| 168 | setupHint: Schema.optional(Schema.String), | 184 | setupHint: Schema.optional(Schema.String), |
| @@ -176,6 +192,20 @@ const CodexPluginEntrySchema = Schema.Struct({ · 7 unmodified lines | |||
| 176 | description: Schema.optional(Schema.String), | 192 | description: Schema.optional(Schema.String), |
| 177 | }); | 193 | }); |
| 178 | 194 | ||
| 195 | /** The result of actually trying the plugin, not a reading of any privacy | ||
| 196 | * database — macOS exposes no way to read another app's decisions. */ | ||
| 197 | const CodexPluginAccessResponse = Schema.Struct({ | ||
| 198 | status: Schema.Literals([ | ||
| 199 | "ok", | ||
| 200 | "blocked", | ||
| 201 | "not-installed", | ||
| 202 | "nothing-to-check", | ||
| 203 | "unknown", | ||
| 204 | "unsupported", | ||
| 205 | ]), | ||
| 206 | message: Schema.optional(Schema.String), | ||
| 207 | }); | ||
| 208 | |||
| 179 | const ListCodexPluginsResponse = Schema.Struct({ | 209 | const ListCodexPluginsResponse = Schema.Struct({ |
| 180 | plugins: Schema.Array(CodexPluginEntrySchema), | 210 | plugins: Schema.Array(CodexPluginEntrySchema), |
| 181 | }); | 211 | }); |
| @@ -252,6 +282,13 @@ export const McpGroup = HttpApiGroup.make("mcp") · 70 unmodified lines | |||
| 252 | error: [InternalError], | 282 | error: [InternalError], |
| 253 | }), | 283 | }), |
| 254 | ) | 284 | ) |
| 285 | .add( | ||
| 286 | HttpApiEndpoint.post("checkCodexPluginAccess", "/mcp/codex-plugins/:id/check", { | ||
| 287 | params: { id: Schema.String }, | ||
| 288 | success: CodexPluginAccessResponse, | ||
| 289 | error: [InternalError], | ||
| 290 | }), | ||
| 291 | ) | ||
| 255 | .add( | 292 | .add( |
| 256 | HttpApiEndpoint.get("getCodexPluginIcon", "/mcp/codex-plugins/:id/icon", { | 293 | HttpApiEndpoint.get("getCodexPluginIcon", "/mcp/codex-plugins/:id/icon", { |
| 257 | params: { id: Schema.String }, | 294 | params: { id: Schema.String }, |
| @@ -31,6 +31,7 @@ const failingExtension: McpPluginExtension = { · 30 unmodified lines | |||
| 31 | configureServer: () => unused, | 31 | configureServer: () => unused, |
| 32 | configureAuth: () => unused, | 32 | configureAuth: () => unused, |
| 33 | listCodexPlugins: () => Effect.succeed([]), | 33 | listCodexPlugins: () => Effect.succeed([]), |
| 34 | checkCodexPluginAccess: () => Effect.succeed({ status: "unknown" as const }), | ||
| 34 | }; | 35 | }; |
| 35 | 36 | ||
| 36 | const Api = addGroup(McpGroup); | 37 | const Api = addGroup(McpGroup); |
| @@ -120,4 +121,37 @@ describe("McpHandlers", () => { · 83 unmodified lines | |||
| 120 | expect(body.message).toContain("Do you need to provide an API key"); | 121 | expect(body.message).toContain("Do you need to provide an API key"); |
| 121 | }), | 122 | }), |
| 122 | ); | 123 | ); |
| 124 | |||
| 125 | it.effect("the remote add carries versionNegotiation through to the extension", () => | ||
| 126 | Effect.gen(function* () { | ||
| 127 | // The probe's legacy retry is pointless if the pin dies at the HTTP | ||
| 128 | // boundary: this exact field was silently stripped by the payload | ||
| 129 | // schema and the handler's explicit field map. | ||
| 130 | let received: unknown; | ||
| 131 | const web = yield* webHandlerFor({ | ||
| 132 | ...failingExtension, | ||
| 133 | addServer: (input) => { | ||
| 134 | received = input; | ||
| 135 | return Effect.succeed({ slug: "pinned", tools: [] } as never); | ||
| 136 | }, | ||
| 137 | }); | ||
| 138 | const response = yield* Effect.promise(() => | ||
| 139 | (web.handler as (request: Request) => Promise<Response>)( | ||
| 140 | new Request("http://localhost/mcp/servers", { | ||
| 141 | method: "POST", | ||
| 142 | headers: { "content-type": "application/json" }, | ||
| 143 | body: JSON.stringify({ | ||
| 144 | transport: "remote", | ||
| 145 | name: "Echoing server", | ||
| 146 | endpoint: "https://example.com/mcp", | ||
| 147 | versionNegotiation: "legacy", | ||
| 148 | authenticationTemplate: [{ kind: "none" }], | ||
| 149 | }), | ||
| 150 | }), | ||
| 151 | ), | ||
| 152 | ); | ||
| 153 | expect(response.status).toBeLessThan(500); | ||
| 154 | expect((received as { versionNegotiation?: string }).versionNegotiation).toBe("legacy"); | ||
| 155 | }), | ||
| 156 | ); | ||
| 123 | }); | 157 | }); |
| @@ -39,10 +39,16 @@ const toServerInput = ( · 38 unmodified lines | |||
| 39 | args?: readonly string[]; | 39 | args?: readonly string[]; |
| 40 | envVars?: readonly string[]; | 40 | envVars?: readonly string[]; |
| 41 | env?: Record<string, string>; | 41 | env?: Record<string, string>; |
| 42 | staticEnv?: Record<string, string>; | ||
| 42 | cwd?: string; | 43 | cwd?: string; |
| 43 | versionNegotiation?: "legacy" | "auto"; | 44 | versionNegotiation?: "legacy" | "auto"; |
| 44 | spawnPerCall?: boolean; | 45 | spawnPerCall?: boolean; |
| 45 | appServer?: { server: string; surface?: "sky" | "browser"; modulePath?: string }; | 46 | appServer?: { |
| 47 | server: string; | ||
| 48 | surface?: "sky" | "browser"; | ||
| 49 | modulePath?: string; | ||
| 50 | presetId?: string; | ||
| 51 | }; | ||
| 46 | slug?: string; | 52 | slug?: string; |
| 47 | }; | 53 | }; |
| 48 | return { | 54 | return { |
| @@ -54,6 +60,7 @@ const toServerInput = ( · 5 unmodified lines | |||
| 54 | args: p.args ? [...p.args] : undefined, | 60 | args: p.args ? [...p.args] : undefined, |
| 55 | envVars: p.envVars ? [...p.envVars] : undefined, | 61 | envVars: p.envVars ? [...p.envVars] : undefined, |
| 56 | env: p.env, | 62 | env: p.env, |
| 63 | staticEnv: p.staticEnv, | ||
| 57 | cwd: p.cwd, | 64 | cwd: p.cwd, |
| 58 | versionNegotiation: p.versionNegotiation, | 65 | versionNegotiation: p.versionNegotiation, |
| 59 | spawnPerCall: p.spawnPerCall, | 66 | spawnPerCall: p.spawnPerCall, |
| @@ -69,6 +76,7 @@ const toServerInput = ( · 9 unmodified lines | |||
| 69 | description?: string; | 76 | description?: string; |
| 70 | endpoint: string; | 77 | endpoint: string; |
| 71 | remoteTransport?: "streamable-http" | "sse" | "auto"; | 78 | remoteTransport?: "streamable-http" | "sse" | "auto"; |
| 79 | versionNegotiation?: "auto" | "legacy"; | ||
| 72 | queryParams?: Record<string, string>; | 80 | queryParams?: Record<string, string>; |
| 73 | headers?: Record<string, string>; | 81 | headers?: Record<string, string>; |
| 74 | slug?: string; | 82 | slug?: string; |
| @@ -85,6 +93,7 @@ const toServerInput = ( · 10 unmodified lines | |||
| 85 | description: p.description, | 93 | description: p.description, |
| 86 | endpoint: p.endpoint, | 94 | endpoint: p.endpoint, |
| 87 | remoteTransport: p.remoteTransport, | 95 | remoteTransport: p.remoteTransport, |
| 96 | versionNegotiation: p.versionNegotiation, | ||
| 88 | queryParams: p.queryParams, | 97 | queryParams: p.queryParams, |
| 89 | headers: p.headers, | 98 | headers: p.headers, |
| 90 | slug: p.slug, | 99 | slug: p.slug, |
| @@ -175,6 +184,14 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 84 unmodified lines | |||
| 175 | }), | 184 | }), |
| 176 | ), | 185 | ), |
| 177 | ) | 186 | ) |
| 187 | .handle("checkCodexPluginAccess", ({ params }) => | ||
| 188 | capture( | ||
| 189 | Effect.gen(function* () { | ||
| 190 | const ext = yield* McpExtensionService; | ||
| 191 | return yield* ext.checkCodexPluginAccess(params.id); | ||
| 192 | }), | ||
| 193 | ), | ||
| 194 | ) | ||
| 178 | .handle("getCodexPluginIcon", ({ params }) => | 195 | .handle("getCodexPluginIcon", ({ params }) => |
| 179 | capture( | 196 | capture( |
| 180 | Effect.gen(function* () { | 197 | Effect.gen(function* () { |
| @@ -37,10 +37,15 @@ import { · 36 unmodified lines | |||
| 37 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 37 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 38 | import type { McpAuthMethodInput } from "../sdk/types"; | 38 | import type { McpAuthMethodInput } from "../sdk/types"; |
| 39 | import { probeMcpEndpoint, addMcpServer } from "./atoms"; | 39 | import { probeMcpEndpoint, addMcpServer } from "./atoms"; |
| 40 | import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | ||
| 40 | import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; | 41 | import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; |
| 41 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; | 42 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; |
| 42 | import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; | 43 | import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; |
| 43 | import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; | 44 | import { |
| 45 | mcpAuthMethodInputFromEditorValue, | ||
| 46 | mcpDetectedAuthSeeds, | ||
| 47 | mcpWireAuthInput, | ||
| 48 | } from "./auth-method-config"; | ||
| 44 | import CodexPluginAdd from "./CodexPluginAdd"; | 49 | import CodexPluginAdd from "./CodexPluginAdd"; |
| 45 | import { parseStdioArgs } from "./stdio-fields"; | 50 | import { parseStdioArgs } from "./stdio-fields"; |
| 46 | import { isProbableMcpEndpoint } from "./probe-url"; | 51 | import { isProbableMcpEndpoint } from "./probe-url"; |
| @@ -79,6 +84,8 @@ type ProbeResult = { · 32 unmodified lines | |||
| 79 | toolCount: number | null; | 84 | toolCount: number | null; |
| 80 | serverName: string | null; | 85 | serverName: string | null; |
| 81 | instructions: string | null; | 86 | instructions: string | null; |
| 87 | /** "legacy" when only the legacy handshake worked (version-echoing server). */ | ||
| 88 | versionNegotiation?: "auto" | "legacy"; | ||
| 82 | }; | 89 | }; |
| 83 | 90 | ||
| 84 | type State = | 91 | type State = |
| @@ -161,6 +168,9 @@ export default function AddMcpIntegration(props: { · 76 unmodified lines | |||
| 161 | onCancel: () => void; | 168 | onCancel: () => void; |
| 162 | initialUrl?: string; | 169 | initialUrl?: string; |
| 163 | initialPreset?: string; | 170 | initialPreset?: string; |
| 171 | initialAuthHeader?: string; | ||
| 172 | initialAuthNote?: string; | ||
| 173 | initialAuthKind?: string; | ||
| 164 | /** Whether the stdio transport is enabled on the server. */ | 174 | /** Whether the stdio transport is enabled on the server. */ |
| 165 | allowStdio?: boolean; | 175 | allowStdio?: boolean; |
| 166 | }) { | 176 | }) { |
| @@ -219,29 +229,16 @@ export default function AddMcpIntegration(props: { · 52 unmodified lines | |||
| 219 | // The probe seeds the method list: detected OAuth → an OAuth row; a 401 | 229 | // The probe seeds the method list: detected OAuth → an OAuth row; a 401 |
| 220 | // without OAuth metadata → a bearer-header row; an open server → a no-auth | 230 | // without OAuth metadata → a bearer-header row; an open server → a no-auth |
| 221 | // row. The user can edit any row or add alternate methods alongside. | 231 | // row. The user can edit any row or add alternate methods alongside. |
| 222 | const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => { | 232 | const authMethodSeeds: readonly AuthMethodSeed[] = useMemo( |
| 223 | if (!probe) return []; | 233 | () => |
| 224 | if (probe.requiresOAuth) { | 234 | mcpDetectedAuthSeeds(probe, { |
| 225 | return [ | 235 | placement: props.initialAuthHeader |
| 226 | { | 236 | ? placementFromHeaderPattern(props.initialAuthHeader) |
| 227 | value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }, | 237 | : null, |
| 228 | label: "Detected", | 238 | kind: props.initialAuthKind, |
| 229 | }, | 239 | }), |
| 230 | ]; | 240 | [probe, props.initialAuthHeader, props.initialAuthKind], |
| 231 | } | 241 | ); |
| 232 | if (probe.requiresAuthentication) { | ||
| 233 | return [ | ||
| 234 | { | ||
| 235 | value: { | ||
| 236 | kind: "apikey", | ||
| 237 | placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], | ||
| 238 | }, | ||
| 239 | label: "Detected", | ||
| 240 | }, | ||
| 241 | ]; | ||
| 242 | } | ||
| 243 | return [{ value: { kind: "none" }, label: "Detected" }]; | ||
| 244 | }, [probe]); | ||
| 245 | const authMethodList = useAuthMethodList(authMethodSeeds); | 242 | const authMethodList = useAuthMethodList(authMethodSeeds); |
| 246 | 243 | ||
| 247 | const remoteIdentity = useIntegrationIdentity({ | 244 | const remoteIdentity = useIntegrationIdentity({ |
| @@ -337,6 +334,12 @@ export default function AddMcpIntegration(props: { · 89 unmodified lines | |||
| 337 | ...(slug ? { slug } : {}), | 334 | ...(slug ? { slug } : {}), |
| 338 | ...(headers ? { headers } : {}), | 335 | ...(headers ? { headers } : {}), |
| 339 | authenticationTemplate, | 336 | authenticationTemplate, |
| 337 | // The probe reports when only legacy negotiation worked (a server | ||
| 338 | // that echoes the modern revision but breaks its contract); pin it | ||
| 339 | // so refreshes and tool calls use the same handshake. | ||
| 340 | ...(probe?.versionNegotiation === "legacy" | ||
| 341 | ? { versionNegotiation: "legacy" as const } | ||
| 342 | : {}), | ||
| 340 | }, | 343 | }, |
| 341 | reactivityKeys: integrationWriteKeys, | 344 | reactivityKeys: integrationWriteKeys, |
| 342 | }); | 345 | }); |
| @@ -496,10 +499,14 @@ export default function AddMcpIntegration(props: { · 153 unmodified lines | |||
| 496 | title="How does this server authenticate?" | 499 | title="How does this server authenticate?" |
| 497 | oauthMetadata="discovered" | 500 | oauthMetadata="discovered" |
| 498 | emptyHint="No methods declared. Add a method, or add the server without auth and connect from the integration page later." | 501 | emptyHint="No methods declared. Add a method, or add the server without auth and connect from the integration page later." |
| 499 | footerHint="Every method here is registered with the server. Connect an account from the integration page after adding." | 502 | footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page." |
| 500 | /> | 503 | /> |
| 501 | )} | 504 | )} |
| 502 | 505 | ||
| 506 | {probe && props.initialAuthNote ? ( | ||
| 507 | <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p> | ||
| 508 | ) : null} | ||
| 509 | |||
| 503 | {/* Error (add server). Probe errors show inline on the field. */} | 510 | {/* Error (add server). Probe errors show inline on the field. */} |
| 504 | {otherError && ( | 511 | {otherError && ( |
| 505 | <div className="space-y-2"> | 512 | <div className="space-y-2"> |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { useState } from "react"; | 1 | import { useEffect, useRef, useState } from "react"; |
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| @@ -9,7 +9,9 @@ import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms"; · 4 unmodified lines | |||
| 9 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 9 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 10 | import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add"; | 10 | import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add"; |
| 11 | 11 | ||
| 12 | import { addMcpServer, codexPluginsAtom } from "./atoms"; | 12 | import { addMcpServer, checkCodexPluginAccess, codexPluginsAtom } from "./atoms"; |
| 13 | import { accessBlocked, accessPending, type CodexAccessState } from "./codex-access-gate"; | ||
| 14 | import { CODEX_PERMISSIONS } from "../sdk/codex-permissions"; | ||
| 13 | 15 | ||
| 14 | // --------------------------------------------------------------------------- | 16 | // --------------------------------------------------------------------------- |
| 15 | // Focused add screen for one Codex plugin, reached from its catalog preset | 17 | // Focused add screen for one Codex plugin, reached from its catalog preset |
| @@ -27,19 +29,35 @@ export default function CodexPluginAdd(props: { · 11 unmodified lines | |||
| 27 | const pluginsResult = useAtomValue(codexPluginsAtom); | 29 | const pluginsResult = useAtomValue(codexPluginsAtom); |
| 28 | const integrationsResult = useAtomValue(integrationsOptimisticAtom); | 30 | const integrationsResult = useAtomValue(integrationsOptimisticAtom); |
| 29 | const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" }); | 31 | const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" }); |
| 32 | const doCheckAccess = useAtomSet(checkCodexPluginAccess, { mode: "promiseExit" }); | ||
| 30 | 33 | ||
| 31 | const [adding, setAdding] = useState(false); | 34 | const [adding, setAdding] = useState(false); |
| 35 | const [access, setAccess] = useState<CodexAccessState | null>(null); | ||
| 36 | const [checking, setChecking] = useState(false); | ||
| 32 | const [error, setError] = useState<string | null>(null); | 37 | const [error, setError] = useState<string | null>(null); |
| 33 | 38 | ||
| 34 | const plugin = AsyncResult.isSuccess(pluginsResult) | 39 | const plugin = AsyncResult.isSuccess(pluginsResult) |
| 35 | ? pluginsResult.value.plugins.find((entry) => entry.id === props.presetId) | 40 | ? pluginsResult.value.plugins.find((entry) => entry.id === props.presetId) |
| 36 | : undefined; | 41 | : undefined; |
| 37 | 42 | ||
| 43 | const permissions = CODEX_PERMISSIONS[props.presetId] ?? []; | ||
| 44 | |||
| 38 | const added = | 45 | const added = |
| 39 | plugin !== undefined && | 46 | plugin !== undefined && |
| 40 | AsyncResult.isSuccess(integrationsResult) && | 47 | AsyncResult.isSuccess(integrationsResult) && |
| 41 | integrationsResult.value.some((integration) => String(integration.slug) === plugin.slug); | 48 | integrationsResult.value.some((integration) => String(integration.slug) === plugin.slug); |
| 42 | 49 | ||
| 50 | // Adding is held back until the probe says the plugin can actually run: an | ||
| 51 | // integration added while macOS is blocking it looks connected and fails on | ||
| 52 | // its first real call, by which point the person has left the one card that | ||
| 53 | // explains the fix. | ||
| 54 | const blocked = accessBlocked(access); | ||
| 55 | const pending = accessPending({ | ||
| 56 | checking, | ||
| 57 | declaresPermissions: permissions.length > 0, | ||
| 58 | access, | ||
| 59 | }); | ||
| 60 | |||
| 43 | const handleAdd = async () => { | 61 | const handleAdd = async () => { |
| 44 | if (plugin === undefined) return; | 62 | if (plugin === undefined) return; |
| 45 | setAdding(true); | 63 | setAdding(true); |
| @@ -53,7 +71,11 @@ export default function CodexPluginAdd(props: { · 7 unmodified lines | |||
| 53 | command: plugin.command, | 71 | command: plugin.command, |
| 54 | args: [...plugin.args], | 72 | args: [...plugin.args], |
| 55 | ...(plugin.cwd !== undefined ? { cwd: plugin.cwd } : {}), | 73 | ...(plugin.cwd !== undefined ? { cwd: plugin.cwd } : {}), |
| 56 | ...(plugin.env !== undefined ? { env: { ...plugin.env } } : {}), | 74 | // As STATIC env, not `env`: these are machine-derived paths the |
| 75 | // scanner already resolved, so they must not become credentials the | ||
| 76 | // person is asked to type. Sent this way the integration declares no | ||
| 77 | // auth, and its connection is created for them. | ||
| 78 | ...(plugin.env !== undefined ? { staticEnv: { ...plugin.env } } : {}), | ||
| 57 | ...(plugin.appServer !== undefined ? { appServer: { ...plugin.appServer } } : {}), | 79 | ...(plugin.appServer !== undefined ? { appServer: { ...plugin.appServer } } : {}), |
| 58 | }, | 80 | }, |
| 59 | reactivityKeys: integrationWriteKeys, | 81 | reactivityKeys: integrationWriteKeys, |
| @@ -66,6 +88,37 @@ export default function CodexPluginAdd(props: { · 6 unmodified lines | |||
| 66 | props.onComplete(exit.value.slug); | 88 | props.onComplete(exit.value.slug); |
| 67 | }; | 89 | }; |
| 68 | 90 | ||
| 91 | // Checked on open rather than on a button, so the card states where you | ||
| 92 | // stand before you commit to anything. It runs the plugin's read-only probe | ||
| 93 | // once per card: macOS offers no way to READ another app's privacy | ||
| 94 | // decisions, so trying it is the only way to know. | ||
| 95 | const checkedFor = useRef<string | null>(null); | ||
| 96 | useEffect(() => { | ||
| 97 | if (!plugin?.available) return; | ||
| 98 | if (checkedFor.current === props.presetId) return; | ||
| 99 | checkedFor.current = props.presetId; | ||
| 100 | void handleCheck(); | ||
| 101 | // `handleCheck` is stable for a given preset; re-running on its identity | ||
| 102 | // would re-probe on every render. | ||
| 103 | // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| 104 | }, [plugin?.available, props.presetId]); | ||
| 105 | |||
| 106 | const handleCheck = async () => { | ||
| 107 | setChecking(true); | ||
| 108 | setAccess(null); | ||
| 109 | // Runs the plugin's own read-only probe down the real path. When macOS has | ||
| 110 | // not asked yet, THIS is what makes it ask — so the check doubles as the | ||
| 111 | // grant flow. | ||
| 112 | // Reads nothing and writes nothing, so no cache is invalidated by it. | ||
| 113 | const exit = await doCheckAccess({ params: { id: props.presetId }, reactivityKeys: [] }); | ||
| 114 | setAccess( | ||
| 115 | Exit.isSuccess(exit) | ||
| 116 | ? (exit.value as CodexAccessState) | ||
| 117 | : { status: "blocked", message: "Could not reach the plugin." }, | ||
| 118 | ); | ||
| 119 | setChecking(false); | ||
| 120 | }; | ||
| 121 | |||
| 69 | if (!AsyncResult.isSuccess(pluginsResult)) { | 122 | if (!AsyncResult.isSuccess(pluginsResult)) { |
| 70 | return ( | 123 | return ( |
| 71 | <div className="flex flex-1 flex-col gap-6"> | 124 | <div className="flex flex-1 flex-col gap-6"> |
| @@ -149,6 +202,73 @@ export default function CodexPluginAdd(props: { · 77 unmodified lines | |||
| 149 | )} | 202 | )} |
| 150 | </div> | 203 | </div> |
| 151 | 204 | ||
| 205 | {/* Stated up front, not discovered on first failure: macOS asks for | ||
| 206 | these ONCE, and a dismissed prompt never returns — after that the | ||
| 207 | plugin fails with an error the user cannot act on. Each row links | ||
| 208 | straight to the pane that holds the switch. */} | ||
| 209 | {permissions.length > 0 && ( | ||
| 210 | <div className="flex flex-col gap-2 rounded-lg border border-border px-3 py-2.5"> | ||
| 211 | <span className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground"> | ||
| 212 | macOS access | ||
| 213 | </span> | ||
| 214 | <p className="text-[12px] text-muted-foreground"> | ||
| 215 | {blocked | ||
| 216 | ? "This has to be on before the plugin can be added — turn it on below, then check again." | ||
| 217 | : "macOS asks the first time this runs. If you miss the prompt, it will not ask again — enable it here."} | ||
| 218 | </p> | ||
| 219 | <div className="flex items-center gap-3"> | ||
| 220 | <span | ||
| 221 | className={ | ||
| 222 | access !== null && access.status !== "ok" | ||
| 223 | ? "text-[12px] text-destructive" | ||
| 224 | : "text-[12px] text-muted-foreground" | ||
| 225 | } | ||
| 226 | > | ||
| 227 | {checking | ||
| 228 | ? "Checking…" | ||
| 229 | : access === null | ||
| 230 | ? "" | ||
| 231 | : access.status === "ok" | ||
| 232 | ? "Allowed — macOS is letting this through." | ||
| 233 | : (access.message ?? "Blocked.")} | ||
| 234 | </span> | ||
| 235 | {access !== null && access.status !== "ok" && !checking && ( | ||
| 236 | <Button type="button" variant="secondary" onClick={() => void handleCheck()}> | ||
| 237 | Check again | ||
| 238 | </Button> | ||
| 239 | )} | ||
| 240 | </div> | ||
| 241 | <ul className="flex flex-col gap-1.5"> | ||
| 242 | {permissions.map((permission) => ( | ||
| 243 | <li key={permission.id} className="flex items-baseline justify-between gap-3"> | ||
| 244 | <span className="text-[12px] text-muted-foreground"> | ||
| 245 | <span className="text-foreground">{permission.label}</span> — {permission.why} | ||
| 246 | </span> | ||
| 247 | <a | ||
| 248 | href={permission.settingsUrl} | ||
| 249 | className="shrink-0 font-mono text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground" | ||
| 250 | > | ||
| 251 | Open settings | ||
| 252 | </a> | ||
| 253 | </li> | ||
| 254 | ))} | ||
| 255 | </ul> | ||
| 256 | </div> | ||
| 257 | )} | ||
| 258 | |||
| 259 | {/* A block on a plugin that declares no macOS access has no panel above | ||
| 260 | to carry it, so state the reason next to the action it is holding. */} | ||
| 261 | {blocked && permissions.length === 0 && ( | ||
| 262 | <div className="flex items-center gap-3"> | ||
| 263 | <p className="text-[12px] text-destructive"> | ||
| 264 | {access?.message ?? "This plugin cannot run yet."} | ||
| 265 | </p> | ||
| 266 | <Button type="button" variant="secondary" onClick={() => void handleCheck()}> | ||
| 267 | Check again | ||
| 268 | </Button> | ||
| 269 | </div> | ||
| 270 | )} | ||
| 271 | |||
| 152 | {error !== null && <p className="text-[12px] text-destructive">{error}</p>} | 272 | {error !== null && <p className="text-[12px] text-destructive">{error}</p>} |
| 153 | 273 | ||
| 154 | <FloatActions> | 274 | <FloatActions> |
| @@ -160,8 +280,13 @@ export default function CodexPluginAdd(props: { · 5 unmodified lines | |||
| 160 | View integration | 280 | View integration |
| 161 | </Button> | 281 | </Button> |
| 162 | ) : plugin.available ? ( | 282 | ) : plugin.available ? ( |
| 163 | <Button type="button" onClick={() => void handleAdd()} loading={adding}> | 283 | <Button |
| 164 | Add integration | 284 | type="button" |
| 285 | onClick={() => void handleAdd()} | ||
| 286 | loading={adding} | ||
| 287 | disabled={blocked || pending} | ||
| 288 | > | ||
| 289 | {pending ? "Checking access…" : "Add integration"} | ||
| 165 | </Button> | 290 | </Button> |
| 166 | ) : ( | 291 | ) : ( |
| 167 | <Button asChild> | 292 | <Button asChild> |
| @@ -29,6 +29,7 @@ export const codexPluginsAtom = McpClient.query("mcp", "listCodexPlugins", { · 28 unmodified lines | |||
| 29 | timeToLive: "15 seconds", | 29 | timeToLive: "15 seconds", |
| 30 | }); | 30 | }); |
| 31 | 31 | ||
| 32 | export const checkCodexPluginAccess = McpClient.mutation("mcp", "checkCodexPluginAccess"); | ||
| 32 | export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint"); | 33 | export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint"); |
| 33 | export const addMcpServer = McpClient.mutation("mcp", "addServer"); | 34 | export const addMcpServer = McpClient.mutation("mcp", "addServer"); |
| 34 | export const removeMcpServer = McpClient.mutation("mcp", "removeServer"); | 35 | export const removeMcpServer = McpClient.mutation("mcp", "removeServer"); |
| @@ -9,6 +9,7 @@ · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | 10 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 11 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; | 11 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; |
| 12 | import type { AuthMethodSeed } from "@executor-js/react/components/auth-method-list-editor"; | ||
| 12 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | 13 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; |
| 13 | import { | 14 | import { |
| 14 | authMethodFromSharedTemplate, | 15 | authMethodFromSharedTemplate, |
| @@ -126,3 +127,55 @@ export function mcpAuthMethodInputsFromPlacements( · 111 unmodified lines | |||
| 126 | if (wire.length === 0) return []; | 127 | if (wire.length === 0) return []; |
| 127 | return [{ kind: "apikey", placements: wire }]; | 128 | return [{ kind: "apikey", placements: wire }]; |
| 128 | } | 129 | } |
| 130 | |||
| 131 | /** The auth methods a registry-driven MCP add declares before any editing: | ||
| 132 | * the probe's detection plus the registry's declared facts. ONE policy for | ||
| 133 | * both the add page (as editor seeds) and the one-click quick add (mapped | ||
| 134 | * straight to wire inputs) — two copies drifted is how the picker and the | ||
| 135 | * add page end up declaring different methods for the same server. */ | ||
| 136 | export function mcpDetectedAuthSeeds( | ||
| 137 | probe: { | ||
| 138 | readonly requiresOAuth: boolean; | ||
| 139 | readonly requiresAuthentication: boolean; | ||
| 140 | } | null, | ||
| 141 | registry: { | ||
| 142 | readonly placement?: Placement | null; | ||
| 143 | readonly kind?: string | undefined; | ||
| 144 | }, | ||
| 145 | ): readonly AuthMethodSeed[] { | ||
| 146 | const registryPlacement = registry.placement ?? null; | ||
| 147 | if (!probe) { | ||
| 148 | // No probe result (pending, or the server was unreachable from here). | ||
| 149 | // The registry's declared facts still stand: an authless server or a | ||
| 150 | // known header pattern seeds the list the probe would have produced. | ||
| 151 | if (registryPlacement) return [{ value: { kind: "apikey", placements: [registryPlacement] } }]; | ||
| 152 | if (registry.kind === "none") return [{ value: { kind: "none" } }]; | ||
| 153 | return []; | ||
| 154 | } | ||
| 155 | if (probe.requiresOAuth) { | ||
| 156 | const oauth: AuthMethodSeed = { | ||
| 157 | value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }, | ||
| 158 | label: "Detected", | ||
| 159 | }; | ||
| 160 | // GitHub's MCP server takes a PAT bearer header in clients without | ||
| 161 | // OAuth; when the registry declared that placement, offer it alongside. | ||
| 162 | return registryPlacement | ||
| 163 | ? [oauth, { value: { kind: "apikey", placements: [registryPlacement] } }] | ||
| 164 | : [oauth]; | ||
| 165 | } | ||
| 166 | if (probe.requiresAuthentication) { | ||
| 167 | // The registry's exact placement beats the generic Bearer guess. | ||
| 168 | return [ | ||
| 169 | { | ||
| 170 | value: { | ||
| 171 | kind: "apikey", | ||
| 172 | placements: [ | ||
| 173 | registryPlacement ?? { carrier: "header", name: "Authorization", prefix: "Bearer " }, | ||
| 174 | ], | ||
| 175 | }, | ||
| 176 | label: "Detected", | ||
| 177 | }, | ||
| 178 | ]; | ||
| 179 | } | ||
| 180 | return [{ value: { kind: "none" }, label: "Detected" }]; | ||
| 181 | } | ||
| @@ -0,0 +1,51 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { accessBlocked, accessPending } from "./codex-access-gate"; | ||
| 4 | |||
| 5 | describe("accessBlocked", () => { | ||
| 6 | it("holds the add only on a refusal", () => { | ||
| 7 | expect(accessBlocked({ status: "blocked", message: "macOS blocked this" })).toBe(true); | ||
| 8 | }); | ||
| 9 | |||
| 10 | it("lets through every answer that is not a refusal", () => { | ||
| 11 | // `unsupported` and `nothing-to-check` are answers about the HOST and the | ||
| 12 | // PLUGIN, not about permission. Treating either as a block would strand a | ||
| 13 | // plugin that needs no grant at all. | ||
| 14 | for (const status of ["ok", "unsupported", "nothing-to-check", "unknown", "not-installed"]) { | ||
| 15 | expect(accessBlocked({ status })).toBe(false); | ||
| 16 | } | ||
| 17 | }); | ||
| 18 | |||
| 19 | it("does not hold before an answer exists", () => { | ||
| 20 | // `accessPending` owns that window; a card without permissions has no | ||
| 21 | // probe to wait for and must not be blocked by its own silence. | ||
| 22 | expect(accessBlocked(null)).toBe(false); | ||
| 23 | }); | ||
| 24 | }); | ||
| 25 | |||
| 26 | describe("accessPending", () => { | ||
| 27 | it("waits while the probe runs", () => { | ||
| 28 | expect(accessPending({ checking: true, declaresPermissions: true, access: null })).toBe(true); | ||
| 29 | // Still pending on a re-check, even though a previous answer is in hand: | ||
| 30 | // that answer is what the user just acted on. | ||
| 31 | expect( | ||
| 32 | accessPending({ checking: true, declaresPermissions: true, access: { status: "blocked" } }), | ||
| 33 | ).toBe(true); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("waits for the first answer on a plugin that declares permissions", () => { | ||
| 37 | expect(accessPending({ checking: false, declaresPermissions: true, access: null })).toBe(true); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("does not wait once an answer has arrived", () => { | ||
| 41 | expect( | ||
| 42 | accessPending({ checking: false, declaresPermissions: true, access: { status: "ok" } }), | ||
| 43 | ).toBe(false); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("does not wait when nothing will be checked", () => { | ||
| 47 | expect(accessPending({ checking: false, declaresPermissions: false, access: null })).toBe( | ||
| 48 | false, | ||
| 49 | ); | ||
| 50 | }); | ||
| 51 | }); | ||
| @@ -0,0 +1,43 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // When the Codex plugin card may hand over its Add button. | ||
| 3 | // | ||
| 4 | // The probe behind this is the only way to know whether macOS will let the | ||
| 5 | // plugin run: privacy decisions are not readable, so the card tries the real | ||
| 6 | // path and reads the answer. Adding before that answer arrives produces an | ||
| 7 | // integration that looks connected and fails on its first call, at a point | ||
| 8 | // where the explanation — and the switch that fixes it — are no longer on | ||
| 9 | // screen. | ||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | |||
| 12 | /** The `checkCodexPluginAccess` result, as the card holds it. */ | ||
| 13 | export interface CodexAccessState { | ||
| 14 | readonly status: string; | ||
| 15 | readonly message?: string; | ||
| 16 | } | ||
| 17 | |||
| 18 | /** | ||
| 19 | * The plugin was reached and refused. | ||
| 20 | * | ||
| 21 | * Only `blocked` counts. `unsupported` (a host that cannot spawn stdio at all) | ||
| 22 | * and `nothing-to-check` (a plugin with no probe) are answers, not blocks, and | ||
| 23 | * holding the button on either would strand plugins that need no permission. | ||
| 24 | */ | ||
| 25 | export const accessBlocked = (access: CodexAccessState | null): boolean => | ||
| 26 | access !== null && access.status === "blocked"; | ||
| 27 | |||
| 28 | /** | ||
| 29 | * The answer is still coming. | ||
| 30 | * | ||
| 31 | * A card that declares permissions runs the probe on open, so `null` there | ||
| 32 | * means the check has not reported yet rather than that nothing will check. | ||
| 33 | * Without permissions there is nothing to wait for, and the button stays live. | ||
| 34 | */ | ||
| 35 | export const accessPending = ({ | ||
| 36 | checking, | ||
| 37 | declaresPermissions, | ||
| 38 | access, | ||
| 39 | }: { | ||
| 40 | readonly checking: boolean; | ||
| 41 | readonly declaresPermissions: boolean; | ||
| 42 | readonly access: CodexAccessState | null; | ||
| 43 | }): boolean => checking || (declaresPermissions && access === null); | ||
| @@ -1,6 +1,21 @@ | |||
| 1 | import { lazy, type ComponentProps, type ComponentType } from "react"; | 1 | import { lazy, useCallback, type ComponentProps, type ComponentType } from "react"; |
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import type { | ||
| 5 | IntegrationPlugin, | ||
| 6 | IntegrationQuickAddInput, | ||
| 7 | IntegrationQuickAddResult, | ||
| 8 | } from "@executor-js/sdk/client"; | ||
| 9 | import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | ||
| 10 | import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; | ||
| 11 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 3 | import { mcpPresets } from "../sdk/presets"; | 12 | import { mcpPresets } from "../sdk/presets"; |
| 13 | import { addMcpServer, probeMcpEndpoint } from "./atoms"; | ||
| 14 | import { | ||
| 15 | mcpAuthMethodInputFromEditorValue, | ||
| 16 | mcpDetectedAuthSeeds, | ||
| 17 | mcpWireAuthInput, | ||
| 18 | } from "./auth-method-config"; | ||
| 4 | 19 | ||
| 5 | const importAdd = () => import("./AddMcpIntegration"); | 20 | const importAdd = () => import("./AddMcpIntegration"); |
| 6 | const importEditSheet = () => import("./EditMcpIntegration"); | 21 | const importEditSheet = () => import("./EditMcpIntegration"); |
| @@ -12,6 +27,51 @@ const LazyMcpAccountsPanel = lazy(importAccounts); · 5 unmodified lines | |||
| 12 | 27 | ||
| 13 | type AddProps = ComponentProps<IntegrationPlugin["add"]>; | 28 | type AddProps = ComponentProps<IntegrationPlugin["add"]>; |
| 14 | 29 | ||
| 30 | /** One-click add for a registry MCP row: probe, declare the auth methods the | ||
| 31 | * add page would have seeded (same policy — `mcpDetectedAuthSeeds`), and | ||
| 32 | * register. A failed probe means the configuration screen's job — it renders | ||
| 33 | * the failure with retry UX — so quick add reports `{ok: false}` instead of | ||
| 34 | * guessing. */ | ||
| 35 | function useMcpQuickAdd(): (input: IntegrationQuickAddInput) => Promise<IntegrationQuickAddResult> { | ||
| 36 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); | ||
| 37 | const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" }); | ||
| 38 | return useCallback( | ||
| 39 | async (input) => { | ||
| 40 | const probeExit = await doProbe({ payload: { endpoint: input.url } }); | ||
| 41 | if (Exit.isFailure(probeExit)) return { ok: false, reason: "probe failed" }; | ||
| 42 | const probe = probeExit.value; | ||
| 43 | const seeds = mcpDetectedAuthSeeds(probe, { | ||
| 44 | placement: input.authHeader ? placementFromHeaderPattern(input.authHeader) : null, | ||
| 45 | kind: input.authKind, | ||
| 46 | }); | ||
| 47 | const methods = seeds.map((seed) => | ||
| 48 | mcpWireAuthInput(mcpAuthMethodInputFromEditorValue(seed.value)), | ||
| 49 | ); | ||
| 50 | const slug = input.slug ? slugifyNamespace(input.slug) : undefined; | ||
| 51 | const addExit = await doAddServer({ | ||
| 52 | payload: { | ||
| 53 | transport: "remote" as const, | ||
| 54 | name: input.name, | ||
| 55 | endpoint: input.url, | ||
| 56 | ...(slug ? { slug } : {}), | ||
| 57 | ...(probe.instructions ? { description: probe.instructions } : {}), | ||
| 58 | authenticationTemplate: methods.length > 0 ? methods : [{ kind: "none" as const }], | ||
| 59 | // The probe reports when only legacy negotiation worked (a server | ||
| 60 | // that echoes the modern revision but breaks its contract); pin it | ||
| 61 | // so refreshes and tool calls use the same handshake. | ||
| 62 | ...(probe.versionNegotiation === "legacy" | ||
| 63 | ? { versionNegotiation: "legacy" as const } | ||
| 64 | : {}), | ||
| 65 | }, | ||
| 66 | reactivityKeys: integrationWriteKeys, | ||
| 67 | }); | ||
| 68 | if (Exit.isFailure(addExit)) return { ok: false, reason: "add failed" }; | ||
| 69 | return { ok: true, slug: String(addExit.value.slug) }; | ||
| 70 | }, | ||
| 71 | [doProbe, doAddServer], | ||
| 72 | ); | ||
| 73 | } | ||
| 74 | |||
| 15 | export interface McpIntegrationPluginOptions { | 75 | export interface McpIntegrationPluginOptions { |
| 16 | /** | 76 | /** |
| 17 | * Enable the stdio transport in the add-integration UI (tab + presets). | 77 | * Enable the stdio transport in the add-integration UI (tab + presets). |
| @@ -32,11 +92,13 @@ export const createMcpIntegrationPlugin = ( · 14 unmodified lines | |||
| 32 | <LazyAddMcpIntegration {...props} allowStdio={allowStdio} /> | 92 | <LazyAddMcpIntegration {...props} allowStdio={allowStdio} /> |
| 33 | ); | 93 | ); |
| 34 | 94 | ||
| 35 | const presets = allowStdio | 95 | const base = allowStdio |
| 36 | ? mcpPresets | 96 | ? mcpPresets |
| 37 | : mcpPresets.filter( | 97 | : mcpPresets.filter( |
| 38 | (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"), | 98 | (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"), |
| 39 | ); | 99 | ); |
| 100 | // Built-ins are registry-listed; a deployment's custom presets would not be. | ||
| 101 | const presets = base.map((p) => ({ ...p, registryListed: true })); | ||
| 40 | 102 | ||
| 41 | return { | 103 | return { |
| 42 | key: "mcp", | 104 | key: "mcp", |
| @@ -50,6 +112,7 @@ export const createMcpIntegrationPlugin = ( · 7 unmodified lines | |||
| 50 | void importEditSheet(); | 112 | void importEditSheet(); |
| 51 | void importAccounts(); | 113 | void importAccounts(); |
| 52 | }, | 114 | }, |
| 115 | useQuickAdd: useMcpQuickAdd, | ||
| 53 | }; | 116 | }; |
| 54 | }; | 117 | }; |
| 55 | 118 | ||
| @@ -23,7 +23,7 @@ const appServerInput = ( · 22 unmodified lines | |||
| 23 | command: "bun", | 23 | command: "bun", |
| 24 | args: ["run", fixture], | 24 | args: ["run", fixture], |
| 25 | env: { CODEX_HOME: "/tmp/fixture-codex-home" }, | 25 | env: { CODEX_HOME: "/tmp/fixture-codex-home" }, |
| 26 | appServer: { server, ...appServer }, | 26 | appServer: { server, presetId: "codex-messages", ...appServer }, |
| 27 | }); | 27 | }); |
| 28 | 28 | ||
| 29 | const withConnection = (input: StdioConnectorInput) => | 29 | const withConnection = (input: StdioConnectorInput) => |
| @@ -42,6 +42,7 @@ describe("codex app-server bridge", () => { · 12 unmodified lines | |||
| 42 | "announce_restart", | 42 | "announce_restart", |
| 43 | "echo", | 43 | "echo", |
| 44 | "needs_approval", | 44 | "needs_approval", |
| 45 | "permission_denied", | ||
| 45 | ]); | 46 | ]); |
| 46 | const echo = tools.tools.find(({ name }) => name === "echo"); | 47 | const echo = tools.tools.find(({ name }) => name === "echo"); |
| 47 | expect(echo?.description).toBe("Echo the arguments back"); | 48 | expect(echo?.description).toBe("Echo the arguments back"); |
| @@ -148,6 +149,27 @@ describe("codex app-server bridge", () => { · 100 unmodified lines | |||
| 148 | ), | 149 | ), |
| 149 | ); | 150 | ); |
| 150 | 151 | ||
| 152 | it.effect("turns a macOS refusal into the grant the user has to enable", () => | ||
| 153 | Effect.scoped( | ||
| 154 | Effect.gen(function* () { | ||
| 155 | // The plugin says "Unknown error" and the code is scrubbed to an | ||
| 156 | // opaque id further up, so the one place this can be made actionable | ||
| 157 | // is here, while the plugin identity is still known. | ||
| 158 | const connection = yield* withConnection(appServerInput("messages")); | ||
| 159 | |||
| 160 | const result = yield* Effect.promise(() => | ||
| 161 | connection.client.callTool({ name: "permission_denied", arguments: {} }), | ||
| 162 | ); | ||
| 163 | const text = (result.content as readonly { readonly text: string }[])[0]!.text; | ||
| 164 | |||
| 165 | expect(result.isError).toBe(true); | ||
| 166 | expect(text, "names the block").toContain("macOS blocked this"); | ||
| 167 | expect(text, "and the exact switch").toContain('"Executor → Messages"'); | ||
| 168 | expect(text, "not the plugin's own wording").not.toContain("Unknown error"); | ||
| 169 | }), | ||
| 170 | ), | ||
| 171 | ); | ||
| 172 | |||
| 151 | // ------------------------------------------------------------------------- | 173 | // ------------------------------------------------------------------------- |
| 152 | // Computer Use: projected onto `node_repl`, not a server of its own. | 174 | // Computer Use: projected onto `node_repl`, not a server of its own. |
| 153 | // ------------------------------------------------------------------------- | 175 | // ------------------------------------------------------------------------- |
| @@ -40,6 +40,7 @@ import type { JSONRPCMessage, JSONRPCRequest, Transport } from "@modelcontextpro · 39 unmodified lines | |||
| 40 | import { Option, Schema } from "effect"; | 40 | import { Option, Schema } from "effect"; |
| 41 | 41 | ||
| 42 | import { browserCallProgram, browserToolList, findBrowserTool } from "./codex-browser-tools"; | 42 | import { browserCallProgram, browserToolList, findBrowserTool } from "./codex-browser-tools"; |
| 43 | import { permissionFailure, permissionFailureMessage } from "./codex-permissions"; | ||
| 43 | import { findSkyTool, skyCallProgram, skyToolList } from "./codex-sky-tools"; | 44 | import { findSkyTool, skyCallProgram, skyToolList } from "./codex-sky-tools"; |
| 44 | import { stdioSpawnEnv, type StdioTransportConfig } from "./stdio-connector"; | 45 | import { stdioSpawnEnv, type StdioTransportConfig } from "./stdio-connector"; |
| 45 | 46 | ||
| @@ -79,6 +80,9 @@ export type AppServerTransportConfig = StdioTransportConfig & { · 33 unmodified lines | |||
| 79 | /** The MCP server name inside Codex whose tools this transport exposes | 80 | /** The MCP server name inside Codex whose tools this transport exposes |
| 80 | * (e.g. `messages`) — the `server` of every `mcpServer/tool/call`. */ | 81 | * (e.g. `messages`) — the `server` of every `mcpServer/tool/call`. */ |
| 81 | readonly server: string; | 82 | readonly server: string; |
| 83 | /** Which curated plugin this is, so a macOS permission failure can name the | ||
| 84 | * exact grant to enable. Absent skips that translation. */ | ||
| 85 | readonly presetId?: string; | ||
| 82 | /** A projected tool surface for a plugin driven through `node_repl` rather | 86 | /** A projected tool surface for a plugin driven through `node_repl` rather |
| 83 | * than serving MCP itself: `sky` is Computer Use (`codex-sky-tools.ts`), | 87 | * than serving MCP itself: `sky` is Computer Use (`codex-sky-tools.ts`), |
| 84 | * `browser` is Chrome (`codex-browser-tools.ts`). Absent exposes the | 88 | * `browser` is Chrome (`codex-browser-tools.ts`). Absent exposes the |
| @@ -473,6 +477,29 @@ class AppServerClientTransport implements Transport { · 388 unmodified lines | |||
| 473 | }); | 477 | }); |
| 474 | return; | 478 | return; |
| 475 | } | 479 | } |
| 480 | |||
| 481 | // A macOS permission denial arrives as a plugin-level error result whose | ||
| 482 | // own text is "Unknown error" — the numeric code is the only signal, and | ||
| 483 | // it is scrubbed to an opaque internal id further up. Replace it here, | ||
| 484 | // while the plugin identity is still known, with the grant to enable. | ||
| 485 | if (result.value.isError === true) { | ||
| 486 | const text = (result.value.content ?? []) | ||
| 487 | .map((block) => (block as { readonly text?: unknown }).text) | ||
| 488 | .filter((value): value is string => typeof value === "string") | ||
| 489 | .join(" "); | ||
| 490 | const permission = permissionFailure(text, this.#config.presetId); | ||
| 491 | if (permission !== null) { | ||
| 492 | this.#emit({ | ||
| 493 | jsonrpc: "2.0", | ||
| 494 | id: message.id, | ||
| 495 | result: { | ||
| 496 | content: [{ type: "text", text: permissionFailureMessage(permission) }], | ||
| 497 | isError: true, | ||
| 498 | }, | ||
| 499 | }); | ||
| 500 | return; | ||
| 501 | } | ||
| 502 | } | ||
| 476 | this.#emit({ | 503 | this.#emit({ |
| 477 | jsonrpc: "2.0", | 504 | jsonrpc: "2.0", |
| 478 | id: message.id, | 505 | id: message.id, |
| @@ -62,6 +62,11 @@ const TOOLS = { · 61 unmodified lines | |||
| 62 | description: "Echo the arguments back", | 62 | description: "Echo the arguments back", |
| 63 | inputSchema: { type: "object", properties: { text: { type: "string" } } }, | 63 | inputSchema: { type: "object", properties: { text: { type: "string" } } }, |
| 64 | }, | 64 | }, |
| 65 | permission_denied: { | ||
| 66 | name: "permission_denied", | ||
| 67 | description: "Fails the way macOS refusal presents", | ||
| 68 | inputSchema: { type: "object", properties: {} }, | ||
| 69 | }, | ||
| 65 | announce_restart: { | 70 | announce_restart: { |
| 66 | name: "announce_restart", | 71 | name: "announce_restart", |
| 67 | description: "Emit server status notifications", | 72 | description: "Emit server status notifications", |
| @@ -224,6 +229,15 @@ const handleToolCall = (id: number | string, params: unknown): void => { · 156 unmodified lines | |||
| 224 | }); | 229 | }); |
| 225 | return; | 230 | return; |
| 226 | } | 231 | } |
| 232 | if (call.tool === "permission_denied") { | ||
| 233 | // Verbatim shape of a macOS TCC refusal: an error result whose only clue | ||
| 234 | // is the numeric code. | ||
| 235 | reply(id, { | ||
| 236 | content: [{ type: "text", text: "Computer Use server error -1743: Unknown error" }], | ||
| 237 | isError: true, | ||
| 238 | }); | ||
| 239 | return; | ||
| 240 | } | ||
| 227 | if (call.tool === "needs_approval") { | 241 | if (call.tool === "needs_approval") { |
| 228 | if (!elicitationsAllowed) { | 242 | if (!elicitationsAllowed) { |
| 229 | // Exactly what Codex returns when it declines the elicitation for the | 243 | // Exactly what Codex returns when it declines the elicitation for the |
| @@ -0,0 +1,79 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | CODEX_PERMISSIONS, | ||
| 5 | permissionFailure, | ||
| 6 | permissionFailureMessage, | ||
| 7 | } from "./codex-permissions"; | ||
| 8 | |||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | // The string below is verbatim what a Codex plugin returns when macOS has | ||
| 11 | // refused the grant — the numeric code is the ONLY signal, since the plugin's | ||
| 12 | // own wording is "Unknown error". | ||
| 13 | // --------------------------------------------------------------------------- | ||
| 14 | |||
| 15 | const DENIED = "Computer Use server error -1743: Unknown error"; | ||
| 16 | |||
| 17 | describe("permissionFailure", () => { | ||
| 18 | it("recognises a denial behind the plugin's own 'Unknown error'", () => { | ||
| 19 | const permission = permissionFailure(DENIED, "codex-messages"); | ||
| 20 | |||
| 21 | expect(permission?.id).toBe("automation"); | ||
| 22 | // Automation is granted per HOST, so the entry names executor, not | ||
| 23 | // anything Codex ships. | ||
| 24 | expect(permission?.entry).toBe("Executor → Messages"); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("recognises the other codes the same denial presents as", () => { | ||
| 28 | for (const code of [-609, -600]) { | ||
| 29 | expect(permissionFailure(`server error ${code}: Unknown error`, "codex-messages")).not.toBe( | ||
| 30 | null, | ||
| 31 | ); | ||
| 32 | } | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("leaves ordinary tool failures alone", () => { | ||
| 36 | expect(permissionFailure("No chat matched that name", "codex-messages")).toBe(null); | ||
| 37 | // A number that merely contains a code must not trip it. | ||
| 38 | expect(permissionFailure("read 21743 rows", "codex-messages")).toBe(null); | ||
| 39 | expect(permissionFailure("error -17430: other", "codex-messages")).toBe(null); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("returns nothing to say for an unknown plugin", () => { | ||
| 43 | expect(permissionFailure(DENIED, undefined)).toBe(null); | ||
| 44 | expect(permissionFailure(DENIED, "codex-openai-docs")).toBe(null); | ||
| 45 | }); | ||
| 46 | }); | ||
| 47 | |||
| 48 | describe("permissionFailureMessage", () => { | ||
| 49 | it("says what was blocked, which switch to flip, and that macOS will not ask again", () => { | ||
| 50 | const permission = permissionFailure(DENIED, "codex-messages")!; | ||
| 51 | const message = permissionFailureMessage(permission); | ||
| 52 | |||
| 53 | expect(message).toContain("macOS blocked this"); | ||
| 54 | expect(message).toContain("Privacy & Security → Automation"); | ||
| 55 | expect(message).toContain('"Executor → Messages"'); | ||
| 56 | expect(message).toContain("only asks once"); | ||
| 57 | }); | ||
| 58 | }); | ||
| 59 | |||
| 60 | describe("CODEX_PERMISSIONS", () => { | ||
| 61 | it("points every entry at a real settings pane", () => { | ||
| 62 | for (const [preset, permissions] of Object.entries(CODEX_PERMISSIONS)) { | ||
| 63 | expect(permissions.length, preset).toBeGreaterThan(0); | ||
| 64 | for (const permission of permissions) { | ||
| 65 | expect(permission.settingsUrl, `${preset}/${permission.id}`).toMatch( | ||
| 66 | /^x-apple\.systempreferences:com\.apple\.preference\.security\?Privacy_/, | ||
| 67 | ); | ||
| 68 | expect(permission.why.length, `${preset}/${permission.id}`).toBeGreaterThan(0); | ||
| 69 | } | ||
| 70 | } | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("attributes the Codex-owned grants to Codex, not to the host", () => { | ||
| 74 | // Screen Recording and Accessibility are held by the Computer Use app, so | ||
| 75 | // granting them once in Codex covers every host — the entry must say so. | ||
| 76 | const computerUse = CODEX_PERMISSIONS["codex-computer-use"]!; | ||
| 77 | expect(computerUse.map((p) => p.entry)).toEqual(["Codex Computer Use", "Codex Computer Use"]); | ||
| 78 | }); | ||
| 79 | }); | ||
| @@ -0,0 +1,130 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // macOS permissions for the Codex plugins. | ||
| 3 | // | ||
| 4 | // These plugins drive the real machine, so macOS gates them behind TCC. Two | ||
| 5 | // facts shape everything here, both established by probing a live install | ||
| 6 | // rather than from documentation: | ||
| 7 | // | ||
| 8 | // * The permissions do NOT all attach to the same identity. Reading Messages | ||
| 9 | // works from a host with no Full Disk Access, because the Codex Computer | ||
| 10 | // Use SERVICE (`com.openai.sky.CUAService`) holds that grant and does the | ||
| 11 | // reading. But sending an Apple Event is attributed to the RESPONSIBLE | ||
| 12 | // process — the app that launched the chain — so Automation is granted per | ||
| 13 | // host. The same call therefore succeeds from a terminal whose app is | ||
| 14 | // approved and fails from a desktop app that is not. | ||
| 15 | // | ||
| 16 | // * A denial is permanent until the user acts. macOS asks once; after that | ||
| 17 | // `-1743` (`errAEEventNotPermitted`) comes back forever and no prompt is | ||
| 18 | // shown again. So "try and see" is not a recovery strategy — the user has | ||
| 19 | // to be told which entry to enable, and taken there. | ||
| 20 | // | ||
| 21 | // The failure this produces is otherwise indecipherable: the plugin reports | ||
| 22 | // "server error -1743: Unknown error", which reaches the caller scrubbed to an | ||
| 23 | // opaque internal error id. Classifying it here is what turns the most common | ||
| 24 | // first-run failure into something a person can act on. | ||
| 25 | // --------------------------------------------------------------------------- | ||
| 26 | |||
| 27 | /** A macOS privacy pane, as a deep link `open` understands. */ | ||
| 28 | const settingsUrl = (pane: string): string => | ||
| 29 | `x-apple.systempreferences:com.apple.preference.security?${pane}`; | ||
| 30 | |||
| 31 | export interface CodexPermission { | ||
| 32 | readonly id: "automation" | "accessibility" | "screen-recording" | "contacts" | "full-disk"; | ||
| 33 | /** What macOS calls this in System Settings. */ | ||
| 34 | readonly label: string; | ||
| 35 | /** Which entry the user must find and enable there. */ | ||
| 36 | readonly entry: string; | ||
| 37 | /** Why this plugin needs it, in the user's terms. */ | ||
| 38 | readonly why: string; | ||
| 39 | readonly settingsUrl: string; | ||
| 40 | } | ||
| 41 | |||
| 42 | /** Automation is per-HOST: the app that spawned the chain is what macOS asks | ||
| 43 | * about, so the entry is executor itself rather than anything Codex ships. */ | ||
| 44 | const automation = (target: string, why: string): CodexPermission => ({ | ||
| 45 | id: "automation", | ||
| 46 | label: "Automation", | ||
| 47 | entry: `Executor → ${target}`, | ||
| 48 | why, | ||
| 49 | settingsUrl: settingsUrl("Privacy_Automation"), | ||
| 50 | }); | ||
| 51 | |||
| 52 | /** Screen Recording and Accessibility attach to the Codex Computer Use app, | ||
| 53 | * which is why granting them once in Codex covers every host. */ | ||
| 54 | const computerUsePermissions: readonly CodexPermission[] = [ | ||
| 55 | { | ||
| 56 | id: "screen-recording", | ||
| 57 | label: "Screen Recording", | ||
| 58 | entry: "Codex Computer Use", | ||
| 59 | why: "so it can see the app it is operating", | ||
| 60 | settingsUrl: settingsUrl("Privacy_ScreenCapture"), | ||
| 61 | }, | ||
| 62 | { | ||
| 63 | id: "accessibility", | ||
| 64 | label: "Accessibility", | ||
| 65 | entry: "Codex Computer Use", | ||
| 66 | why: "so it can click, type, and scroll", | ||
| 67 | settingsUrl: settingsUrl("Privacy_Accessibility"), | ||
| 68 | }, | ||
| 69 | ]; | ||
| 70 | |||
| 71 | const messagesPermissions: readonly CodexPermission[] = [ | ||
| 72 | automation("Messages", "so it can read your chats and send on your behalf"), | ||
| 73 | { | ||
| 74 | id: "contacts", | ||
| 75 | label: "Contacts", | ||
| 76 | entry: "Codex Computer Use", | ||
| 77 | why: "so it can turn names into the right phone numbers", | ||
| 78 | settingsUrl: settingsUrl("Privacy_Contacts"), | ||
| 79 | }, | ||
| 80 | ]; | ||
| 81 | |||
| 82 | /** What each plugin needs, keyed by preset id. Absent means nothing beyond | ||
| 83 | * having Codex installed. */ | ||
| 84 | export const CODEX_PERMISSIONS: Readonly<Record<string, readonly CodexPermission[]>> = { | ||
| 85 | "codex-messages": messagesPermissions, | ||
| 86 | "codex-computer-use": computerUsePermissions, | ||
| 87 | "codex-computer-history": computerUsePermissions, | ||
| 88 | "codex-chrome": [automation("Google Chrome", "so it can drive your browser")], | ||
| 89 | }; | ||
| 90 | |||
| 91 | // --------------------------------------------------------------------------- | ||
| 92 | // Classification | ||
| 93 | // --------------------------------------------------------------------------- | ||
| 94 | |||
| 95 | /** Apple Event failures that mean "the user has not allowed this", as opposed | ||
| 96 | * to a target that is missing or busy. | ||
| 97 | * | ||
| 98 | * `-1743` is `errAEEventNotPermitted`: the grant was DECLINED, or never | ||
| 99 | * answered and has since defaulted closed. `-600`/`-609` are the connection | ||
| 100 | * errors macOS returns when it refuses to hand the sender a port at all, | ||
| 101 | * which is how the same denial presents on some paths. */ | ||
| 102 | const APPLE_EVENT_DENIED = [-1743, -609, -600] as const; | ||
| 103 | |||
| 104 | const deniedCodePattern = new RegExp(`(?:^|[^0-9-])(${APPLE_EVENT_DENIED.join("|")})(?![0-9])`); | ||
| 105 | |||
| 106 | /** A permission failure recognised in an upstream tool error, or null when the | ||
| 107 | * error is about something else. Matching is on the numeric code, not on | ||
| 108 | * wording: the plugin's own text is "Unknown error". */ | ||
| 109 | export const permissionFailure = ( | ||
| 110 | message: string, | ||
| 111 | presetId: string | undefined, | ||
| 112 | ): CodexPermission | null => { | ||
| 113 | if (!deniedCodePattern.test(message)) return null; | ||
| 114 | const permissions = presetId === undefined ? [] : (CODEX_PERMISSIONS[presetId] ?? []); | ||
| 115 | // Automation is the one a host can actually be missing; the Codex-owned | ||
| 116 | // grants are shared and fail differently. Fall back to the first declared. | ||
| 117 | return permissions.find((permission) => permission.id === "automation") ?? permissions[0] ?? null; | ||
| 118 | }; | ||
| 119 | |||
| 120 | /** The message a caller sees instead of "Unknown error". | ||
| 121 | * | ||
| 122 | * Written for whoever reads it next — a person, or a model relaying to one. | ||
| 123 | * It names the block, the exact entry to enable, and where, because macOS | ||
| 124 | * will not ask again on its own. */ | ||
| 125 | export const permissionFailureMessage = (permission: CodexPermission): string => | ||
| 126 | [ | ||
| 127 | `macOS blocked this: ${permission.label} access has not been allowed.`, | ||
| 128 | `Open System Settings → Privacy & Security → ${permission.label}, find "${permission.entry}", and turn it on — ${permission.why}.`, | ||
| 129 | "macOS only asks once, so a prompt will not appear again until it is enabled there.", | ||
| 130 | ].join(" "); | ||
| @@ -18,6 +18,10 @@ export interface CuratedCodexPlugin { · 17 unmodified lines | |||
| 18 | /** The MCP server name this plugin registers inside Codex — the `server` | 18 | /** The MCP server name this plugin registers inside Codex — the `server` |
| 19 | * the app-server bridge calls tools against. */ | 19 | * the app-server bridge calls tools against. */ |
| 20 | readonly server: string; | 20 | readonly server: string; |
| 21 | /** A read-only tool that exercises this plugin's macOS permissions, used to | ||
| 22 | * CHECK access without changing anything. Chosen for having no side | ||
| 23 | * effects: listing, reading status. Absent means nothing to check. */ | ||
| 24 | readonly probeTool?: { readonly name: string; readonly args: Readonly<Record<string, unknown>> }; | ||
| 21 | /** A public image for this plugin, used when the machine-local icon cannot | 25 | /** A public image for this plugin, used when the machine-local icon cannot |
| 22 | * be read (i.e. Codex is not installed here). Only some plugins have one | 26 | * be read (i.e. Codex is not installed here). Only some plugins have one |
| 23 | * published; the rest fall back to the provider's mark. */ | 27 | * published; the rest fall back to the provider's mark. */ |
| @@ -92,6 +96,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ · 68 unmodified lines | |||
| 92 | pluginName: "messages", | 96 | pluginName: "messages", |
| 93 | name: "Messages", | 97 | name: "Messages", |
| 94 | slug: "codex_messages", | 98 | slug: "codex_messages", |
| 99 | probeTool: { name: "find_chats", args: { limit: 1 } }, | ||
| 95 | // Served by the app itself (`packages/app/public`), because the Messages | 100 | // Served by the app itself (`packages/app/public`), because the Messages |
| 96 | // app icon is published nowhere hotlinkable: it is a system app, absent | 101 | // app icon is published nowhere hotlinkable: it is a system app, absent |
| 97 | // from the App Store artwork API, and `messages.apple.com` resolves to | 102 | // from the App Store artwork API, and `messages.apple.com` resolves to |
| @@ -107,6 +112,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ · 9 unmodified lines | |||
| 107 | pluginName: "computer-use", | 112 | pluginName: "computer-use", |
| 108 | name: "Computer Use", | 113 | name: "Computer Use", |
| 109 | slug: "codex_computer_use", | 114 | slug: "codex_computer_use", |
| 115 | probeTool: { name: "list_apps", args: {} }, | ||
| 110 | publicIcon: "https://learn.chatgpt.com/images/codex/icons/computer-use-plugin-icon.png", | 116 | publicIcon: "https://learn.chatgpt.com/images/codex/icons/computer-use-plugin-icon.png", |
| 111 | requires: "computer-use-app", | 117 | requires: "computer-use-app", |
| 112 | server: "node_repl", | 118 | server: "node_repl", |
| @@ -121,6 +127,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ · 8 unmodified lines | |||
| 121 | pluginName: "chrome", | 127 | pluginName: "chrome", |
| 122 | name: "Chrome", | 128 | name: "Chrome", |
| 123 | slug: "codex_chrome", | 129 | slug: "codex_chrome", |
| 130 | probeTool: { name: "list_tabs", args: {} }, | ||
| 124 | publicIcon: "https://learn.chatgpt.com/images/codex/icons/chrome-production-large.png", | 131 | publicIcon: "https://learn.chatgpt.com/images/codex/icons/chrome-production-large.png", |
| 125 | server: "node_repl", | 132 | server: "node_repl", |
| 126 | surface: "browser", | 133 | surface: "browser", |
| @@ -145,6 +152,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ · 18 unmodified lines | |||
| 145 | pluginName: "computer-history", | 152 | pluginName: "computer-history", |
| 146 | name: "Computer History", | 153 | name: "Computer History", |
| 147 | slug: "codex_computer_history", | 154 | slug: "codex_computer_history", |
| 155 | probeTool: { name: "computer_history_status", args: {} }, | ||
| 148 | requires: "computer-use-app", | 156 | requires: "computer-use-app", |
| 149 | server: "computer-history", | 157 | server: "computer-history", |
| 150 | summary: | 158 | summary: |
| @@ -138,12 +138,19 @@ describe("scanCodexPlugins", () => { · 137 unmodified lines | |||
| 138 | // Codex: both ship as node-repl content, so they target `node_repl` with a | 138 | // Codex: both ship as node-repl content, so they target `node_repl` with a |
| 139 | // projected surface. Chrome additionally carries the module its surface | 139 | // projected surface. Chrome additionally carries the module its surface |
| 140 | // imports, resolved through the version-proof `latest` symlink. | 140 | // imports, resolved through the version-proof `latest` symlink. |
| 141 | // Each entry also carries its own preset id, so a macOS permission | ||
| 142 | // failure can name the exact grant that plugin needs. | ||
| 141 | expect(curated.map((entry) => entry.appServer)).toEqual([ | 143 | expect(curated.map((entry) => entry.appServer)).toEqual([ |
| 142 | { server: "messages" }, | 144 | { presetId: "codex-messages", server: "messages" }, |
| 143 | { server: "node_repl", surface: "sky" }, | 145 | { presetId: "codex-computer-use", server: "node_repl", surface: "sky" }, |
| 144 | { server: "node_repl", surface: "browser", modulePath: join(home, CHROME_CLIENT_RELATIVE) }, | 146 | { |
| 145 | { server: "openaiDeveloperDocs" }, | 147 | presetId: "codex-chrome", |
| 146 | { server: "computer-history" }, | 148 | server: "node_repl", |
| 149 | surface: "browser", | ||
| 150 | modulePath: join(home, CHROME_CLIENT_RELATIVE), | ||
| 151 | }, | ||
| 152 | { presetId: "codex-openai-docs", server: "openaiDeveloperDocs" }, | ||
| 153 | { presetId: "codex-computer-history", server: "computer-history" }, | ||
| 147 | ]); | 154 | ]); |
| 148 | }); | 155 | }); |
| 149 | 156 | ||
| @@ -47,6 +47,7 @@ export interface CodexPluginEntry { · 46 unmodified lines | |||
| 47 | * connector bridges MCP to it in process, calling tools on this named | 47 | * connector bridges MCP to it in process, calling tools on this named |
| 48 | * server inside Codex. See `appserver-connector.ts`. */ | 48 | * server inside Codex. See `appserver-connector.ts`. */ |
| 49 | readonly appServer?: { | 49 | readonly appServer?: { |
| 50 | readonly presetId?: string; | ||
| 50 | readonly server: string; | 51 | readonly server: string; |
| 51 | readonly surface?: "sky" | "browser"; | 52 | readonly surface?: "sky" | "browser"; |
| 52 | readonly modulePath?: string; | 53 | readonly modulePath?: string; |
| @@ -420,6 +421,7 @@ export const scanCodexPlugins = (options?: { · 367 unmodified lines | |||
| 420 | args: ["app-server"], | 421 | args: ["app-server"], |
| 421 | env: { CODEX_HOME: codexHome }, | 422 | env: { CODEX_HOME: codexHome }, |
| 422 | appServer: { | 423 | appServer: { |
| 424 | presetId: entry.id, | ||
| 423 | server: entry.server, | 425 | server: entry.server, |
| 424 | ...(entry.surface === undefined ? {} : { surface: entry.surface }), | 426 | ...(entry.surface === undefined ? {} : { surface: entry.surface }), |
| 425 | ...(entry.surface === "browser" ? { modulePath: browserClient } : {}), | 427 | ...(entry.surface === "browser" ? { modulePath: browserClient } : {}), |
| @@ -566,7 +566,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 565 unmodified lines | |||
| 566 | // bridge answers the MCP handshake itself, so `versionNegotiation` does | 566 | // bridge answers the MCP handshake itself, so `versionNegotiation` does |
| 567 | // not apply on this path. | 567 | // not apply on this path. |
| 568 | if (input.appServer !== undefined) { | 568 | if (input.appServer !== undefined) { |
| 569 | const { server, surface, modulePath } = input.appServer; | 569 | const { server, surface, modulePath, presetId } = input.appServer; |
| 570 | return Effect.gen(function* () { | 570 | return Effect.gen(function* () { |
| 571 | const { createAppServerTransport } = yield* Effect.tryPromise({ | 571 | const { createAppServerTransport } = yield* Effect.tryPromise({ |
| 572 | try: () => import("./appserver-connector"), | 572 | try: () => import("./appserver-connector"), |
| @@ -588,6 +588,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 15 unmodified lines | |||
| 588 | server, | 588 | server, |
| 589 | ...(surface === undefined ? {} : { surface }), | 589 | ...(surface === undefined ? {} : { surface }), |
| 590 | ...(modulePath === undefined ? {} : { modulePath }), | 590 | ...(modulePath === undefined ? {} : { modulePath }), |
| 591 | ...(presetId === undefined ? {} : { presetId }), | ||
| 591 | }), | 592 | }), |
| 592 | }); | 593 | }); |
| 593 | }); | 594 | }); |
| @@ -634,13 +635,14 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 40 unmodified lines | |||
| 634 | 635 | ||
| 635 | const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); | 636 | const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); |
| 636 | 637 | ||
| 637 | // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable | 638 | // Auto-negotiate the 2026-07-28 era on Streamable HTTP unless the config |
| 638 | // HTTP. SSE is a legacy-only transport; stdio negotiates per the | 639 | // pins `legacy` (for servers that echo the proposed revision and then |
| 639 | // integration's `versionNegotiation` (default legacy — see the stdio | 640 | // violate its contract). SSE is a legacy-only transport; stdio negotiates |
| 640 | // branch above). | 641 | // per the integration's `versionNegotiation` (default legacy — see the |
| 642 | // stdio branch above). | ||
| 641 | const connectStreamableHttp = connectClient({ | 643 | const connectStreamableHttp = connectClient({ |
| 642 | transport: "streamable-http", | 644 | transport: "streamable-http", |
| 643 | versionNegotiation: { mode: "auto" }, | 645 | ...(input.versionNegotiation === "legacy" ? {} : { versionNegotiation: { mode: "auto" } }), |
| 644 | createTransport: (sdk) => | 646 | createTransport: (sdk) => |
| 645 | new sdk.client.StreamableHTTPClientTransport(endpoint, { | 647 | new sdk.client.StreamableHTTPClientTransport(endpoint, { |
| 646 | requestInit, | 648 | requestInit, |
| @@ -2,10 +2,11 @@ · 1 unmodified line | |||
| 2 | // MCP tool discovery — connect to an MCP server and list its tools | 2 | // MCP tool discovery — connect to an MCP server and list its tools |
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | ||
| 5 | import { Duration, Effect, Option, Predicate } from "effect"; | 5 | import { Duration, Effect, Option, Predicate, Schema } from "effect"; |
| 6 | 6 | ||
| 7 | import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection"; | 7 | import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection"; |
| 8 | import { McpToolDiscoveryError } from "./errors"; | 8 | import { McpToolDiscoveryError } from "./errors"; |
| 9 | import { createMcpConnector, type ConnectorInput } from "./connection"; | ||
| 9 | import { httpStatusFromCause } from "./http-status"; | 10 | import { httpStatusFromCause } from "./http-status"; |
| 10 | import { | 11 | import { |
| 11 | decodeListToolsPage, | 12 | decodeListToolsPage, |
| @@ -34,6 +35,14 @@ const DEFAULT_DISCOVER_TIMEOUT = Duration.seconds(15); · 22 unmodified lines | |||
| 34 | // Public API | 35 | // Public API |
| 35 | // --------------------------------------------------------------------------- | 36 | // --------------------------------------------------------------------------- |
| 36 | 37 | ||
| 38 | /** The SDK rejects with an Error subclass carrying a stable `code`; decode | ||
| 39 | * the boundary instead of stringifying an unknown. */ | ||
| 40 | const SdkFailure = Schema.Struct({ | ||
| 41 | code: Schema.optional(Schema.String), | ||
| 42 | message: Schema.optional(Schema.String), | ||
| 43 | }); | ||
| 44 | const decodeSdkFailure = Schema.decodeUnknownOption(SdkFailure); | ||
| 45 | |||
| 37 | /** | 46 | /** |
| 38 | * List every tool from an open MCP connection, following `nextCursor` | 47 | * List every tool from an open MCP connection, following `nextCursor` |
| 39 | * pagination (spec: `tools/list` is a paginated operation — a single call | 48 | * pagination (spec: `tools/list` is a paginated operation — a single call |
| @@ -68,14 +77,23 @@ const listAllTools = ( · 28 unmodified lines | |||
| 68 | reauthorizationRequired: true, | 77 | reauthorizationRequired: true, |
| 69 | }); | 78 | }); |
| 70 | } | 79 | } |
| 80 | const failure = Option.getOrNull(decodeSdkFailure(cause)); | ||
| 81 | // A modern-era connection whose server breaks the modern response | ||
| 82 | // contract means the server echoed our proposed revision without | ||
| 83 | // implementing it; callers can retry with legacy negotiation. | ||
| 84 | const modernContractViolation = | ||
| 85 | connection.client.getProtocolEra?.() === "modern" && failure?.code === "INVALID_RESULT"; | ||
| 71 | const httpStatus = httpStatusFromCause(cause); | 86 | const httpStatus = httpStatusFromCause(cause); |
| 72 | return new McpToolDiscoveryError({ | 87 | return new McpToolDiscoveryError({ |
| 73 | stage: "list_tools", | 88 | stage: "list_tools", |
| 74 | message: | 89 | message: |
| 75 | httpStatus === undefined | 90 | failure?.message !== undefined |
| 76 | ? "Failed listing MCP tools" | 91 | ? `Failed listing MCP tools: ${failure.message.slice(0, 300)}` |
| 77 | : `Failed listing MCP tools (HTTP ${httpStatus})`, | 92 | : httpStatus === undefined |
| 93 | ? "Failed listing MCP tools" | ||
| 94 | : `Failed listing MCP tools (HTTP ${httpStatus})`, | ||
| 78 | ...(httpStatus === undefined ? {} : { httpStatus }), | 95 | ...(httpStatus === undefined ? {} : { httpStatus }), |
| 96 | ...(modernContractViolation ? { modernContractViolation: true } : {}), | ||
| 79 | }); | 97 | }); |
| 80 | }, | 98 | }, |
| 81 | }); | 99 | }); |
| @@ -103,6 +121,39 @@ const listAllTools = ( · 21 unmodified lines | |||
| 103 | ); | 121 | ); |
| 104 | }); | 122 | }); |
| 105 | 123 | ||
| 124 | /** Discovery that survives version-echoing servers. Some servers answer the | ||
| 125 | * proposed 2026-07-28 revision affirmatively while emitting 2024-era results | ||
| 126 | * (Walmart's MCP), which the modern client rightly rejects. On that exact | ||
| 127 | * signature, retry once with legacy negotiation and report which one worked, | ||
| 128 | * so an add flow can pin `versionNegotiation: "legacy"` on the integration. */ | ||
| 129 | export const discoverToolsFromInput = ( | ||
| 130 | input: ConnectorInput, | ||
| 131 | timeoutMs?: number, | ||
| 132 | ): Effect.Effect< | ||
| 133 | { readonly manifest: McpToolManifest; readonly versionNegotiation: "auto" | "legacy" | null }, | ||
| 134 | McpToolDiscoveryError | ||
| 135 | > => | ||
| 136 | discoverTools(createMcpConnector(input), timeoutMs).pipe( | ||
| 137 | Effect.map((manifest) => ({ | ||
| 138 | manifest, | ||
| 139 | versionNegotiation: input.transport === "stdio" ? null : (input.versionNegotiation ?? "auto"), | ||
| 140 | })), | ||
| 141 | Effect.catch((error) => { | ||
| 142 | const retriable = | ||
| 143 | error.modernContractViolation === true && | ||
| 144 | input.transport !== "stdio" && | ||
| 145 | input.versionNegotiation !== "legacy"; | ||
| 146 | if (!retriable) return Effect.fail(error); | ||
| 147 | return discoverTools( | ||
| 148 | createMcpConnector({ ...input, versionNegotiation: "legacy" }), | ||
| 149 | timeoutMs, | ||
| 150 | ).pipe( | ||
| 151 | Effect.map((manifest) => ({ manifest, versionNegotiation: "legacy" as const })), | ||
| 152 | Effect.withSpan("mcp.discover.legacy_retry"), | ||
| 153 | ); | ||
| 154 | }), | ||
| 155 | ); | ||
| 156 | |||
| 106 | /** | 157 | /** |
| 107 | * Connect to an MCP server and discover all available tools. | 158 | * Connect to an MCP server and discover all available tools. |
| 108 | * Returns the parsed manifest containing server metadata and tool entries. | 159 | * Returns the parsed manifest containing server metadata and tool entries. |
| @@ -43,6 +43,11 @@ export class McpToolDiscoveryError extends Schema.TaggedErrorClass<McpToolDiscov · 42 unmodified lines | |||
| 43 | /** HTTP status from the underlying connect or tools/list failure, when | 43 | /** HTTP status from the underlying connect or tools/list failure, when |
| 44 | * known. */ | 44 | * known. */ |
| 45 | httpStatus: Schema.optional(Schema.Number), | 45 | httpStatus: Schema.optional(Schema.Number), |
| 46 | /** The connection negotiated the modern (2026-07-28) era and the server | ||
| 47 | * then broke that revision's response contract — the signature of a | ||
| 48 | * server that echoes whatever protocol version is proposed. Retrying | ||
| 49 | * with legacy negotiation is expected to succeed. */ | ||
| 50 | modernContractViolation: Schema.optional(Schema.Boolean), | ||
| 46 | /** The MCP OAuth provider reached the interactive authorization boundary. | 51 | /** The MCP OAuth provider reached the interactive authorization boundary. |
| 47 | * Catalog callers use this structural signal to request reconnect without | 52 | * Catalog callers use this structural signal to request reconnect without |
| 48 | * parsing or exposing an upstream error message. */ | 53 | * parsing or exposing an upstream error message. */ |
| @@ -24,7 +24,7 @@ import { · 23 unmodified lines | |||
| 24 | } from "@executor-js/sdk/testing"; | 24 | } from "@executor-js/sdk/testing"; |
| 25 | 25 | ||
| 26 | import { createMcpConnector } from "./connection"; | 26 | import { createMcpConnector } from "./connection"; |
| 27 | import { mcpPlugin, userFacingProbeMessage } from "./plugin"; | 27 | import { mcpPlugin, userFacingProbeMessage, toIntegrationConfig } from "./plugin"; |
| 28 | import { McpInvocationError } from "./errors"; | 28 | import { McpInvocationError } from "./errors"; |
| 29 | import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; | 29 | import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; |
| 30 | import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; | 30 | import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; |
| @@ -1536,3 +1536,40 @@ describe("mcpPlugin endpoint telemetry", () => { · 1505 unmodified lines | |||
| 1536 | }), | 1536 | }), |
| 1537 | ); | 1537 | ); |
| 1538 | }); | 1538 | }); |
| 1539 | |||
| 1540 | describe("stdio static env", () => { | ||
| 1541 | it("keeps non-secret env off the credential surface", () => { | ||
| 1542 | // `env` declares a credential the user must type; `staticEnv` is machine | ||
| 1543 | // knowledge stored on the integration. A path the scanner already resolved | ||
| 1544 | // belongs in the second, or adding the integration asks for it. | ||
| 1545 | const config = toIntegrationConfig({ | ||
| 1546 | transport: "stdio", | ||
| 1547 | name: "Computer Use", | ||
| 1548 | command: "/usr/local/bin/codex", | ||
| 1549 | args: ["app-server"], | ||
| 1550 | staticEnv: { CODEX_HOME: "/home/a/.codex" }, | ||
| 1551 | }); | ||
| 1552 | |||
| 1553 | expect(config).toMatchObject({ | ||
| 1554 | env: { CODEX_HOME: "/home/a/.codex" }, | ||
| 1555 | authenticationTemplate: [{ slug: "none", kind: "none" }], | ||
| 1556 | }); | ||
| 1557 | }); | ||
| 1558 | |||
| 1559 | it("still treats declared env values as credentials", () => { | ||
| 1560 | const config = toIntegrationConfig({ | ||
| 1561 | transport: "stdio", | ||
| 1562 | name: "Secret server", | ||
| 1563 | command: "run", | ||
| 1564 | env: { API_KEY: "sk-live" }, | ||
| 1565 | }); | ||
| 1566 | |||
| 1567 | expect(config).toMatchObject({ | ||
| 1568 | authenticationTemplate: [{ slug: "env", kind: "stdio_env", vars: ["API_KEY"] }], | ||
| 1569 | }); | ||
| 1570 | expect( | ||
| 1571 | (config as { env?: unknown }).env, | ||
| 1572 | "the secret never lands in the config", | ||
| 1573 | ).toBeUndefined(); | ||
| 1574 | }); | ||
| 1575 | }); | ||
| @@ -45,7 +45,7 @@ import { · 44 unmodified lines | |||
| 45 | import type { CodexPluginEntry } from "./codex-plugins"; | 45 | import type { CodexPluginEntry } from "./codex-plugins"; |
| 46 | import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection"; | 46 | import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection"; |
| 47 | import { createMcpConnectionPool } from "./connection-pool"; | 47 | import { createMcpConnectionPool } from "./connection-pool"; |
| 48 | import { discoverTools } from "./discover"; | 48 | import { discoverToolsFromInput } from "./discover"; |
| 49 | import { | 49 | import { |
| 50 | McpConnectionError, | 50 | McpConnectionError, |
| 51 | type McpConnectionFailureKind, | 51 | type McpConnectionFailureKind, |
| @@ -218,6 +218,10 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 166 unmodified lines | |||
| 218 | /** Single-method shorthand (legacy callers). Ignored when | 218 | /** Single-method shorthand (legacy callers). Ignored when |
| 219 | * `authenticationTemplate` is present. Defaults to none. */ | 219 | * `authenticationTemplate` is present. Defaults to none. */ |
| 220 | auth: Schema.optional(McpAuthShorthand), | 220 | auth: Schema.optional(McpAuthShorthand), |
| 221 | /** Pin protocol negotiation. `legacy` is for servers that echo the proposed | ||
| 222 | * 2026-07-28 revision and then violate its response contract; the probe | ||
| 223 | * reports when it had to fall back, and the add flow passes that through. */ | ||
| 224 | versionNegotiation: Schema.optional(McpStdioVersionNegotiation), | ||
| 221 | }); | 225 | }); |
| 222 | 226 | ||
| 223 | const McpStdioServerInputSchema = Schema.Struct({ | 227 | const McpStdioServerInputSchema = Schema.Struct({ |
| @@ -236,6 +240,17 @@ const McpStdioServerInputSchema = Schema.Struct({ · 12 unmodified lines | |||
| 236 | * add then auto-creates the connection holding them. The UI uses `envVars` | 240 | * add then auto-creates the connection holding them. The UI uses `envVars` |
| 237 | * instead and leaves the values to the connect step. */ | 241 | * instead and leaves the values to the connect step. */ |
| 238 | env: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 242 | env: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 243 | /** Non-secret environment the server needs, stored on the integration and | ||
| 244 | * injected verbatim at spawn. | ||
| 245 | * | ||
| 246 | * Separate from `env` because that channel makes every variable a | ||
| 247 | * CREDENTIAL: it is declared as a `stdio_env` method and the user is asked | ||
| 248 | * to type its value on a masked form. That is right for an API key and | ||
| 249 | * wrong for a machine-derived path — a Codex plugin's `CODEX_HOME` is | ||
| 250 | * already known to the scanner, is not a secret, and must never become a | ||
| 251 | * field a person has to fill in. Nothing here is a credential, so it does | ||
| 252 | * not appear in `authenticationTemplate`. */ | ||
| 253 | staticEnv: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 239 | cwd: Schema.optional(Schema.String), | 254 | cwd: Schema.optional(Schema.String), |
| 240 | /** Protocol negotiation at connect: `auto` probes `server/discover` (spec | 255 | /** Protocol negotiation at connect: `auto` probes `server/discover` (spec |
| 241 | * 2026-07-28) for modern-only servers. Defaults to the legacy `initialize` | 256 | * 2026-07-28) for modern-only servers. Defaults to the legacy `initialize` |
| @@ -253,6 +268,7 @@ const McpStdioServerInputSchema = Schema.Struct({ · 11 unmodified lines | |||
| 253 | server: Schema.String, | 268 | server: Schema.String, |
| 254 | surface: Schema.optional(Schema.Literals(["sky", "browser"])), | 269 | surface: Schema.optional(Schema.Literals(["sky", "browser"])), |
| 255 | modulePath: Schema.optional(Schema.String), | 270 | modulePath: Schema.optional(Schema.String), |
| 271 | presetId: Schema.optional(Schema.String), | ||
| 256 | }), | 272 | }), |
| 257 | ), | 273 | ), |
| 258 | slug: Schema.optional(Schema.String), | 274 | slug: Schema.optional(Schema.String), |
| @@ -294,6 +310,10 @@ const McpProbeEndpointOutputSchema = Schema.Struct({ · 35 unmodified lines | |||
| 294 | /** The server's `instructions` from initialize — prefill for the add form's | 310 | /** The server's `instructions` from initialize — prefill for the add form's |
| 295 | * description. Only available when the probe connected unauthenticated. */ | 311 | * description. Only available when the probe connected unauthenticated. */ |
| 296 | instructions: Schema.NullOr(Schema.String), | 312 | instructions: Schema.NullOr(Schema.String), |
| 313 | /** Present when discovery succeeded: which protocol negotiation worked. | ||
| 314 | * `legacy` means the server echoed the modern revision and then broke its | ||
| 315 | * contract — the add should pin `versionNegotiation: "legacy"`. */ | ||
| 316 | versionNegotiation: Schema.optional(McpStdioVersionNegotiation), | ||
| 297 | }); | 317 | }); |
| 298 | 318 | ||
| 299 | // --------------------------------------------------------------------------- | 319 | // --------------------------------------------------------------------------- |
| @@ -406,18 +426,23 @@ const stdioEnvVarNames = (input: McpStdioServerInput): readonly string[] => { · 106 unmodified lines | |||
| 406 | return [...names]; | 426 | return [...names]; |
| 407 | }; | 427 | }; |
| 408 | 428 | ||
| 409 | const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => { | 429 | /** Exported for tests: the credential/non-credential split is a security |
| 430 | * boundary (a value in `env` becomes something the user is asked to type), | ||
| 431 | * and asserting it through the whole add flow would not show it. */ | ||
| 432 | export const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => { | ||
| 410 | if (input.transport === "stdio") { | 433 | if (input.transport === "stdio") { |
| 411 | // The config only DECLARES the secret env vars by NAME (a `stdio_env` | 434 | // The config only DECLARES the secret env vars by NAME (a `stdio_env` |
| 412 | // method); their values are credentials and live on the connection, never | 435 | // method); their values are credentials and live on the connection, never |
| 413 | // in this blob. Names come from the explicit `envVars` declaration and/or | 436 | // in this blob. Names come from the explicit `envVars` declaration and/or |
| 414 | // the keys of any one-shot `env` values. | 437 | // the keys of any one-shot `env` values. |
| 415 | const vars = stdioEnvVarNames(input); | 438 | const vars = stdioEnvVarNames(input); |
| 439 | const staticEnv = input.staticEnv; | ||
| 416 | return { | 440 | return { |
| 417 | transport: "stdio", | 441 | transport: "stdio", |
| 418 | family: input.family?.trim() || undefined, | 442 | family: input.family?.trim() || undefined, |
| 419 | command: input.command, | 443 | command: input.command, |
| 420 | args: input.args ? [...input.args] : undefined, | 444 | args: input.args ? [...input.args] : undefined, |
| 445 | env: staticEnv !== undefined && Object.keys(staticEnv).length > 0 ? staticEnv : undefined, | ||
| 421 | cwd: input.cwd, | 446 | cwd: input.cwd, |
| 422 | versionNegotiation: input.versionNegotiation, | 447 | versionNegotiation: input.versionNegotiation, |
| 423 | spawnPerCall: input.spawnPerCall, | 448 | spawnPerCall: input.spawnPerCall, |
| @@ -438,6 +463,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 14 unmodified lines | |||
| 438 | authenticationTemplate: input.authenticationTemplate | 463 | authenticationTemplate: input.authenticationTemplate |
| 439 | ? normalizeMcpAuthMethods(input.authenticationTemplate) | 464 | ? normalizeMcpAuthMethods(input.authenticationTemplate) |
| 440 | : [mcpAuthMethodFromShorthand(input.auth ?? { kind: "none" })], | 465 | : [mcpAuthMethodFromShorthand(input.auth ?? { kind: "none" })], |
| 466 | versionNegotiation: input.versionNegotiation, | ||
| 441 | }; | 467 | }; |
| 442 | }; | 468 | }; |
| 443 | 469 | ||
| @@ -679,6 +705,7 @@ const buildConnectorInput = ( · 235 unmodified lines | |||
| 679 | authProvider, | 705 | authProvider, |
| 680 | ...(authProvider === undefined ? {} : { staticOAuthBearer: true }), | 706 | ...(authProvider === undefined ? {} : { staticOAuthBearer: true }), |
| 681 | httpClientLayer, | 707 | httpClientLayer, |
| 708 | versionNegotiation: config.versionNegotiation, | ||
| 682 | }); | 709 | }); |
| 683 | }; | 710 | }; |
| 684 | 711 | ||
| @@ -941,17 +968,17 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 256 unmodified lines | |||
| 941 | const probeHeaders = typeof input === "string" ? undefined : input.headers; | 968 | const probeHeaders = typeof input === "string" ? undefined : input.headers; |
| 942 | const probeQueryParams = typeof input === "string" ? undefined : input.queryParams; | 969 | const probeQueryParams = typeof input === "string" ? undefined : input.queryParams; |
| 943 | 970 | ||
| 944 | const connector = createMcpConnector({ | 971 | const result = yield* discoverToolsFromInput({ |
| 945 | transport: "remote", | 972 | transport: "remote", |
| 946 | endpoint: trimmed, | 973 | endpoint: trimmed, |
| 947 | headers: probeHeaders, | 974 | headers: probeHeaders, |
| 948 | queryParams: probeQueryParams, | 975 | queryParams: probeQueryParams, |
| 949 | httpClientLayer, | 976 | httpClientLayer, |
| 950 | }); | 977 | }).pipe( |
| 951 | 978 | Effect.map((d) => ({ ok: true as const, ...d })), | |
| 952 | const result = yield* discoverTools(connector).pipe( | 979 | Effect.catch(() => |
| 953 | Effect.map((m) => ({ ok: true as const, manifest: m })), | 980 | Effect.succeed({ ok: false as const, manifest: null, versionNegotiation: null }), |
| 954 | Effect.catch(() => Effect.succeed({ ok: false as const, manifest: null })), | 981 | ), |
| 955 | Effect.withSpan("mcp.plugin.discover_tools"), | 982 | Effect.withSpan("mcp.plugin.discover_tools"), |
| 956 | ); | 983 | ); |
| 957 | 984 | ||
| @@ -966,6 +993,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 8 unmodified lines | |||
| 966 | toolCount: result.manifest.tools.length, | 993 | toolCount: result.manifest.tools.length, |
| 967 | serverName: result.manifest.server?.name ?? null, | 994 | serverName: result.manifest.server?.name ?? null, |
| 968 | instructions: result.manifest.server?.instructions ?? null, | 995 | instructions: result.manifest.server?.instructions ?? null, |
| 996 | ...(result.versionNegotiation === "legacy" | ||
| 997 | ? { versionNegotiation: "legacy" as const } | ||
| 998 | : {}), | ||
| 969 | } satisfies McpProbeResult; | 999 | } satisfies McpProbeResult; |
| 970 | } | 1000 | } |
| 971 | 1001 | ||
| @@ -1361,6 +1391,82 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 389 unmodified lines | |||
| 1361 | // scanner touches node:fs, so it stays behind a dynamic import (the | 1391 | // scanner touches node:fs, so it stays behind a dynamic import (the |
| 1362 | // stdio-connector pattern) and behind the stdio gate: with stdio off the | 1392 | // stdio-connector pattern) and behind the stdio gate: with stdio off the |
| 1363 | // presets could not be added anyway. | 1393 | // presets could not be added anyway. |
| 1394 | /** Ask a Codex plugin whether macOS will actually let it work. | ||
| 1395 | * | ||
| 1396 | * Runs the plugin's own read-only probe tool down the REAL path — the | ||
| 1397 | * same bridge, spawn, and service a live call uses — because that is | ||
| 1398 | * the only honest answer available. macOS exposes no way to read | ||
| 1399 | * another app's privacy decisions, and the grants here are split across | ||
| 1400 | * two identities (the host holds Automation; the Codex service holds | ||
| 1401 | * the rest), so nothing short of trying it can tell the user where they | ||
| 1402 | * stand. A denial is reported as the grant to enable. | ||
| 1403 | * | ||
| 1404 | * Safe to run on demand: every probe tool is a listing or a status | ||
| 1405 | * read. If macOS has not yet asked, this is what makes it ask. */ | ||
| 1406 | const checkCodexPluginAccess = (id: string) => | ||
| 1407 | Effect.gen(function* () { | ||
| 1408 | if (!allowStdio) return { status: "unsupported" as const }; | ||
| 1409 | const plugins = yield* listCodexPlugins(); | ||
| 1410 | const plugin = plugins.find((entry) => entry.id === id); | ||
| 1411 | if (plugin === undefined) return { status: "unknown" as const }; | ||
| 1412 | if (!plugin.available) return { status: "not-installed" as const }; | ||
| 1413 | |||
| 1414 | const mod = yield* Effect.promise(() => import("./codex-plugin-presets")); | ||
| 1415 | const preset = mod.CURATED_CODEX_PLUGINS.find((entry) => entry.id === id); | ||
| 1416 | const probe = preset?.probeTool; | ||
| 1417 | if (probe === undefined) return { status: "nothing-to-check" as const }; | ||
| 1418 | |||
| 1419 | const connector = createMcpConnector({ | ||
| 1420 | transport: "stdio", | ||
| 1421 | command: plugin.command, | ||
| 1422 | args: plugin.args, | ||
| 1423 | env: plugin.env === undefined ? undefined : { ...plugin.env }, | ||
| 1424 | ...(plugin.appServer === undefined ? {} : { appServer: plugin.appServer }), | ||
| 1425 | }); | ||
| 1426 | |||
| 1427 | return yield* Effect.gen(function* () { | ||
| 1428 | const connection = yield* connector; | ||
| 1429 | const result = yield* Effect.tryPromise({ | ||
| 1430 | try: () => connection.client.callTool({ name: probe.name, arguments: probe.args }), | ||
| 1431 | catch: () => | ||
| 1432 | new McpConnectionError({ | ||
| 1433 | transport: "appserver", | ||
| 1434 | message: "The plugin did not answer.", | ||
| 1435 | }), | ||
| 1436 | }).pipe(Effect.ensuring(Effect.promise(() => connection.close()))); | ||
| 1437 | |||
| 1438 | const text = (Array.isArray(result.content) ? result.content : []) | ||
| 1439 | .map((block) => (block as { readonly text?: unknown }).text) | ||
| 1440 | .filter((value): value is string => typeof value === "string") | ||
| 1441 | .join(" "); | ||
| 1442 | if (result.isError === true) { | ||
| 1443 | return { status: "blocked" as const, message: text }; | ||
| 1444 | } | ||
| 1445 | return { status: "ok" as const }; | ||
| 1446 | }).pipe( | ||
| 1447 | // Any failure to even reach the plugin is reported the same way a | ||
| 1448 | // refusal is: the user cares that it does not work and why, not | ||
| 1449 | // which layer said no. | ||
| 1450 | // Distinguish the two ways this can fail by TAG, not by reading a | ||
| 1451 | // message off an unknown: the plugin refused, or we never reached | ||
| 1452 | // it at all. | ||
| 1453 | Effect.catchTags({ | ||
| 1454 | McpConnectionError: () => | ||
| 1455 | Effect.succeed({ | ||
| 1456 | status: "blocked" as const, | ||
| 1457 | message: | ||
| 1458 | "Could not start the plugin. Check that Codex is installed and signed in.", | ||
| 1459 | }), | ||
| 1460 | McpOAuthReauthorizationRequired: () => | ||
| 1461 | Effect.succeed({ | ||
| 1462 | status: "blocked" as const, | ||
| 1463 | message: "The plugin needs to be re-authorized in Codex.", | ||
| 1464 | }), | ||
| 1465 | }), | ||
| 1466 | Effect.withSpan("mcp.plugin.check_codex_plugin_access"), | ||
| 1467 | ); | ||
| 1468 | }); | ||
| 1469 | |||
| 1364 | const listCodexPlugins = () => | 1470 | const listCodexPlugins = () => |
| 1365 | allowStdio | 1471 | allowStdio |
| 1366 | ? Effect.promise(() => import("./codex-plugins")).pipe( | 1472 | ? Effect.promise(() => import("./codex-plugins")).pipe( |
| @@ -1378,6 +1484,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 11 unmodified lines | |||
| 1378 | configureServer, | 1484 | configureServer, |
| 1379 | configureAuth, | 1485 | configureAuth, |
| 1380 | listCodexPlugins, | 1486 | listCodexPlugins, |
| 1487 | checkCodexPluginAccess, | ||
| 1381 | }; | 1488 | }; |
| 1382 | }, | 1489 | }, |
| 1383 | 1490 | ||
| @@ -1408,10 +1515,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 24 unmodified lines | |||
| 1408 | template === null ? null : String(template), | 1515 | template === null ? null : String(template), |
| 1409 | allowStdio, | 1516 | allowStdio, |
| 1410 | httpClientLayer, | 1517 | httpClientLayer, |
| 1411 | ).pipe( | 1518 | ).pipe(Effect.result); |
| 1412 | Effect.map((ci) => createMcpConnector(ci)), | ||
| 1413 | Effect.result, | ||
| 1414 | ); | ||
| 1415 | 1519 | ||
| 1416 | if (Result.isFailure(built)) { | 1520 | if (Result.isFailure(built)) { |
| 1417 | return { | 1521 | return { |
| @@ -1421,7 +1525,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 3 unmodified lines | |||
| 1421 | }; | 1525 | }; |
| 1422 | } | 1526 | } |
| 1423 | 1527 | ||
| 1424 | const discovered = yield* discoverTools(built.success).pipe( | 1528 | const discovered = yield* discoverToolsFromInput(built.success).pipe( |
| 1425 | Effect.result, | 1529 | Effect.result, |
| 1426 | Effect.withSpan("mcp.plugin.discover_tools", { | 1530 | Effect.withSpan("mcp.plugin.discover_tools", { |
| 1427 | attributes: { "mcp.connection.name": String(connection.name) }, | 1531 | attributes: { "mcp.connection.name": String(connection.name) }, |
| @@ -1444,7 +1548,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 16 unmodified lines | |||
| 1444 | : {}), | 1548 | : {}), |
| 1445 | }; | 1549 | }; |
| 1446 | } | 1550 | } |
| 1447 | return { tools: discovered.success.tools.map(toToolDef) }; | 1551 | return { tools: discovered.success.manifest.tools.map(toToolDef) }; |
| 1448 | }).pipe( | 1552 | }).pipe( |
| 1449 | Effect.withSpan("mcp.plugin.resolve_tools", { | 1553 | Effect.withSpan("mcp.plugin.resolve_tools", { |
| 1450 | attributes: { "mcp.connection.name": String(connection.name) }, | 1554 | attributes: { "mcp.connection.name": String(connection.name) }, |
| @@ -1660,13 +1764,11 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 209 unmodified lines | |||
| 1660 | const name = parsed.value.hostname || "mcp"; | 1764 | const name = parsed.value.hostname || "mcp"; |
| 1661 | const slug = deriveMcpNamespace({ endpoint: trimmed }); | 1765 | const slug = deriveMcpNamespace({ endpoint: trimmed }); |
| 1662 | 1766 | ||
| 1663 | const connector = createMcpConnector({ | 1767 | const connected = yield* discoverToolsFromInput({ |
| 1664 | transport: "remote", | 1768 | transport: "remote", |
| 1665 | endpoint: trimmed, | 1769 | endpoint: trimmed, |
| 1666 | httpClientLayer, | 1770 | httpClientLayer, |
| 1667 | }); | 1771 | }).pipe( |
| 1668 | |||
| 1669 | const connected = yield* discoverTools(connector).pipe( | ||
| 1670 | Effect.map(() => true), | 1772 | Effect.map(() => true), |
| 1671 | Effect.catch(() => Effect.succeed(false)), | 1773 | Effect.catch(() => Effect.succeed(false)), |
| 1672 | Effect.withSpan("mcp.plugin.discover_tools"), | 1774 | Effect.withSpan("mcp.plugin.discover_tools"), |
| @@ -1778,9 +1880,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 105 unmodified lines | |||
| 1778 | credential.template === null ? null : String(credential.template), | 1880 | credential.template === null ? null : String(credential.template), |
| 1779 | allowStdio, | 1881 | allowStdio, |
| 1780 | options?.httpClientLayer ?? ctx.httpClientLayer, | 1882 | options?.httpClientLayer ?? ctx.httpClientLayer, |
| 1781 | ).pipe(Effect.map((ci) => createMcpConnector(ci))); | 1883 | ); |
| 1782 | 1884 | ||
| 1783 | return yield* discoverTools(connector).pipe( | 1885 | return yield* discoverToolsFromInput(connector).pipe( |
| 1784 | Effect.map( | 1886 | Effect.map( |
| 1785 | () => | 1887 | () => |
| 1786 | ({ status: "healthy" as const, checkedAt: Date.now() }) satisfies HealthCheckResult, | 1888 | ({ status: "healthy" as const, checkedAt: Date.now() }) satisfies HealthCheckResult, |
| @@ -1944,4 +2046,17 @@ export interface McpPluginExtension { · 157 unmodified lines | |||
| 1944 | /** Locally installed Codex plugins with stdio MCP servers, as one-click | 2046 | /** Locally installed Codex plugins with stdio MCP servers, as one-click |
| 1945 | * presets. Empty when stdio is disabled. */ | 2047 | * presets. Empty when stdio is disabled. */ |
| 1946 | readonly listCodexPlugins: () => Effect.Effect<readonly CodexPluginEntry[], never>; | 2048 | readonly listCodexPlugins: () => Effect.Effect<readonly CodexPluginEntry[], never>; |
| 2049 | readonly checkCodexPluginAccess: (id: string) => Effect.Effect< | ||
| 2050 | { | ||
| 2051 | readonly status: | ||
| 2052 | | "ok" | ||
| 2053 | | "blocked" | ||
| 2054 | | "not-installed" | ||
| 2055 | | "nothing-to-check" | ||
| 2056 | | "unknown" | ||
| 2057 | | "unsupported"; | ||
| 2058 | readonly message?: string; | ||
| 2059 | }, | ||
| 2060 | never | ||
| 2061 | >; | ||
| 1947 | } | 2062 | } |
| @@ -57,14 +57,6 @@ const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map( · 56 unmodified lines | |||
| 57 | })); | 57 | })); |
| 58 | 58 | ||
| 59 | export const mcpPresets: readonly McpPreset[] = [ | 59 | export const mcpPresets: readonly McpPreset[] = [ |
| 60 | { | ||
| 61 | id: "emulate-mcp", | ||
| 62 | name: "Emulate MCP", | ||
| 63 | summary: "Deterministic MCP fixtures for validating native text and image content.", | ||
| 64 | url: "https://emulators.dev/mcp/query/mcp?token=demo-token", | ||
| 65 | endpoint: "https://emulators.dev/mcp/query/mcp?token=demo-token", | ||
| 66 | icon: "https://integrations.sh/logo/emulators.dev", | ||
| 67 | }, | ||
| 68 | { | 60 | { |
| 69 | id: "deepwiki", | 61 | id: "deepwiki", |
| 70 | name: "DeepWiki", | 62 | name: "DeepWiki", |
| @@ -231,6 +231,12 @@ export const McpRemoteIntegrationConfig = Schema.Struct({ · 230 unmodified lines | |||
| 231 | /** Declared auth methods — how a connection's values are rendered onto | 231 | /** Declared auth methods — how a connection's values are rendered onto |
| 232 | * requests. A connection's `template` picks one by slug. */ | 232 | * requests. A connection's `template` picks one by slug. */ |
| 233 | authenticationTemplate: Schema.Array(McpAuthMethod), | 233 | authenticationTemplate: Schema.Array(McpAuthMethod), |
| 234 | /** Protocol negotiation. Remote defaults to `auto` (2026-07-28 era). | ||
| 235 | * `legacy` pins servers that ECHO the proposed revision and then violate | ||
| 236 | * its response contract — Walmart's MCP answers "2026-07-28" to any | ||
| 237 | * proposal while emitting 2024-era results, which the modern client | ||
| 238 | * rightly rejects. */ | ||
| 239 | versionNegotiation: Schema.optional(McpStdioVersionNegotiation), | ||
| 234 | }); | 240 | }); |
| 235 | export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type; | 241 | export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type; |
| 236 | 242 | ||
| @@ -283,6 +289,9 @@ export const McpStdioIntegrationConfig = Schema.Struct({ · 46 unmodified lines | |||
| 283 | * Chrome's `browser-client.mjs`). Machine-specific, so it is resolved | 289 | * Chrome's `browser-client.mjs`). Machine-specific, so it is resolved |
| 284 | * by the scanner rather than hardcoded. */ | 290 | * by the scanner rather than hardcoded. */ |
| 285 | modulePath: Schema.optional(Schema.String), | 291 | modulePath: Schema.optional(Schema.String), |
| 292 | /** Which curated Codex plugin this is, so a macOS permission failure can | ||
| 293 | * name the exact grant to enable. */ | ||
| 294 | presetId: Schema.optional(Schema.String), | ||
| 286 | }), | 295 | }), |
| 287 | ), | 296 | ), |
| 288 | /** Declared auth methods — a single `stdio_env` method naming the secret env | 297 | /** Declared auth methods — a single `stdio_env` method naming the secret env |
| @@ -1,5 +1,23 @@ | |||
| 1 | # @executor-js/plugin-onepassword | 1 | # @executor-js/plugin-onepassword |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: | ||
| 8 | - @executor-js/react@1.4.69 | ||
| 9 | - @executor-js/api@1.4.69 | ||
| 10 | - @executor-js/sdk@1.6.6 | ||
| 11 | |||
| 12 | ## 1.6.5 | ||
| 13 | |||
| 14 | ### Patch Changes | ||
| 15 | |||
| 16 | - Updated dependencies []: | ||
| 17 | - @executor-js/sdk@1.6.5 | ||
| 18 | - @executor-js/api@1.4.68 | ||
| 19 | - @executor-js/react@1.4.68 | ||
| 20 | |||
| 3 | ## 1.6.4 | 21 | ## 1.6.4 |
| 4 | 22 | ||
| 5 | ### Patch Changes | 23 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-onepassword", | 2 | "name": "@executor-js/plugin-onepassword", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/onepassword", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/onepassword", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -1,5 +1,25 @@ | |||
| 1 | # @executor-js/plugin-openapi | 1 | # @executor-js/plugin-openapi |
| 2 | 2 | ||
| 3 | ## 1.6.6 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: | ||
| 8 | - @executor-js/react@1.4.69 | ||
| 9 | - @executor-js/api@1.4.69 | ||
| 10 | - @executor-js/sdk@1.6.6 | ||
| 11 | - @executor-js/config@1.6.6 | ||
| 12 | |||
| 13 | ## 1.6.5 | ||
| 14 | |||
| 15 | ### Patch Changes | ||
| 16 | |||
| 17 | - Updated dependencies []: | ||
| 18 | - @executor-js/sdk@1.6.5 | ||
| 19 | - @executor-js/config@1.6.5 | ||
| 20 | - @executor-js/api@1.4.68 | ||
| 21 | - @executor-js/react@1.4.68 | ||
| 22 | |||
| 3 | ## 1.6.4 | 23 | ## 1.6.4 |
| 4 | 24 | ||
| 5 | ### Patch Changes | 25 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-openapi", | 2 | "name": "@executor-js/plugin-openapi", |
| 3 | "version": "1.6.4", | 3 | "version": "1.6.6", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/openapi", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/openapi", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -82,6 +82,9 @@ const AddSpecPayload = Schema.Struct({ · 81 unmodified lines | |||
| 82 | name: Schema.optional(Schema.String), | 82 | name: Schema.optional(Schema.String), |
| 83 | description: Schema.optional(Schema.String), | 83 | description: Schema.optional(Schema.String), |
| 84 | baseUrl: Schema.optional(Schema.String), | 84 | baseUrl: Schema.optional(Schema.String), |
| 85 | /** The product's domain when the caller knew it (a registry row names | ||
| 86 | * notion.com) — display identity when the spec lives on a code host. */ | ||
| 87 | displayDomain: Schema.optional(Schema.String), | ||
| 85 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 88 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 86 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 89 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 87 | specFormat: Schema.optional(Schema.String), | 90 | specFormat: Schema.optional(Schema.String), |
| @@ -63,6 +63,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 62 unmodified lines | |||
| 63 | name: payload.name, | 63 | name: payload.name, |
| 64 | description: payload.description, | 64 | description: payload.description, |
| 65 | baseUrl: payload.baseUrl, | 65 | baseUrl: payload.baseUrl, |
| 66 | displayDomain: payload.displayDomain, | ||
| 66 | headers: payload.headers ? { ...payload.headers } : undefined, | 67 | headers: payload.headers ? { ...payload.headers } : undefined, |
| 67 | queryParams: payload.queryParams ? { ...payload.queryParams } : undefined, | 68 | queryParams: payload.queryParams ? { ...payload.queryParams } : undefined, |
| 68 | specFormat: payload.specFormat, | 69 | specFormat: payload.specFormat, |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Option, Schema } from "effect"; | 2 | import { Effect, Option, Schema } from "effect"; |
| 3 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 3 | import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; | 4 | import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; |
| 4 | 5 | ||
| 5 | import { | 6 | import { |
| @@ -7,6 +8,7 @@ import { · 1 unmodified line | |||
| 7 | convertGoogleDiscoveryToOpenApi, | 8 | convertGoogleDiscoveryToOpenApi, |
| 8 | isGoogleDiscoveryUrl, | 9 | isGoogleDiscoveryUrl, |
| 9 | normalizeGoogleDiscoveryUrl, | 10 | normalizeGoogleDiscoveryUrl, |
| 11 | fetchGoogleDiscoveryDocument, | ||
| 10 | } from "./discovery"; | 12 | } from "./discovery"; |
| 11 | import { googleOAuthConsentScopesForPreset } from "./service-policy"; | 13 | import { googleOAuthConsentScopesForPreset } from "./service-policy"; |
| 12 | import { extract, parse } from "@executor-js/plugin-openapi"; | 14 | import { extract, parse } from "@executor-js/plugin-openapi"; |
| @@ -1669,3 +1671,29 @@ it.effect("marks People API semantic inputs as required", () => · 1656 unmodified lines | |||
| 1669 | }); | 1671 | }); |
| 1670 | }), | 1672 | }), |
| 1671 | ); | 1673 | ); |
| 1674 | |||
| 1675 | it("falls back to the service-hosted Discovery URL when the central directory has no entry", async () => { | ||
| 1676 | // Google Ads is not listed in the central directory: normalization maps it | ||
| 1677 | // onto www.googleapis.com for identity, and that URL 404s. The fetch must | ||
| 1678 | // still succeed by falling back to the host the caller named. | ||
| 1679 | const seen: string[] = []; | ||
| 1680 | const stub = HttpClient.make((request) => { | ||
| 1681 | seen.push(request.url); | ||
| 1682 | return Effect.succeed( | ||
| 1683 | request.url.includes("www.googleapis.com") | ||
| 1684 | ? HttpClientResponse.fromWeb(request, new Response("nope", { status: 404 })) | ||
| 1685 | : HttpClientResponse.fromWeb( | ||
| 1686 | request, | ||
| 1687 | new Response('{"name":"googleads"}', { status: 200 }), | ||
| 1688 | ), | ||
| 1689 | ); | ||
| 1690 | }); | ||
| 1691 | const text = await Effect.runPromise( | ||
| 1692 | fetchGoogleDiscoveryDocument( | ||
| 1693 | "https://googleads.googleapis.com/$discovery/rest?version=v24", | ||
| 1694 | ).pipe(Effect.provideService(HttpClient.HttpClient, stub)), | ||
| 1695 | ); | ||
| 1696 | expect(text).toBe('{"name":"googleads"}'); | ||
| 1697 | expect(seen[0]).toContain("www.googleapis.com"); | ||
| 1698 | expect(seen[1]).toBe("https://googleads.googleapis.com/$discovery/rest?version=v24"); | ||
| 1699 | }); | ||
| @@ -343,6 +343,25 @@ export const isGoogleDiscoveryUrl = (url: string): boolean => { · 342 unmodified lines | |||
| 343 | return normalizeGoogleDiscoveryUrl(url) !== null; | 343 | return normalizeGoogleDiscoveryUrl(url) !== null; |
| 344 | }; | 344 | }; |
| 345 | 345 | ||
| 346 | /** The service's own Discovery endpoint, for a URL that named it. Normalization | ||
| 347 | * canonicalizes most services onto the central directory, which is right for | ||
| 348 | * identity but NOT universally fetchable: services outside the directory | ||
| 349 | * (Google Ads) answer only on their own host. */ | ||
| 350 | const serviceHostedDiscoveryUrl = (discoveryUrl: string): string | null => { | ||
| 351 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() rejects malformed input | ||
| 352 | try { | ||
| 353 | const parsed = new URL(discoveryUrl.trim()); | ||
| 354 | const host = parsed.hostname.toLowerCase(); | ||
| 355 | if (host === "www.googleapis.com" || !host.endsWith(".googleapis.com")) return null; | ||
| 356 | if (!["/$discovery/rest", "/$discovery/rest/"].includes(parsed.pathname)) return null; | ||
| 357 | const version = parsed.searchParams.get("version")?.trim(); | ||
| 358 | if (!version || !DISCOVERY_VERSION_RE.test(version)) return null; | ||
| 359 | return `https://${host}/$discovery/rest?version=${version}`; | ||
| 360 | } catch { | ||
| 361 | return null; | ||
| 362 | } | ||
| 363 | }; | ||
| 364 | |||
| 346 | export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")( | 365 | export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")( |
| 347 | function* (discoveryUrl: string, credentials?: SpecFetchCredentials) { | 366 | function* (discoveryUrl: string, credentials?: SpecFetchCredentials) { |
| 348 | const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl); | 367 | const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl); |
| @@ -353,35 +372,50 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov · 4 unmodified lines | |||
| 353 | }); | 372 | }); |
| 354 | } | 373 | } |
| 355 | const client = yield* HttpClient.HttpClient; | 374 | const client = yield* HttpClient.HttpClient; |
| 356 | const requestUrl = new URL(normalizedDiscoveryUrl); | 375 | |
| 357 | for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { | 376 | const attempt = (target: string) => |
| 358 | requestUrl.searchParams.set(name, value); | 377 | Effect.gen(function* () { |
| 359 | } | 378 | const requestUrl = new URL(target); |
| 360 | let request = HttpClientRequest.get(requestUrl.toString()).pipe( | 379 | for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { |
| 361 | HttpClientRequest.setHeader("Accept", "application/json, */*"), | 380 | requestUrl.searchParams.set(name, value); |
| 362 | ); | 381 | } |
| 363 | for (const [name, value] of Object.entries(credentials?.headers ?? {})) { | 382 | let request = HttpClientRequest.get(requestUrl.toString()).pipe( |
| 364 | request = HttpClientRequest.setHeader(request, name, value); | 383 | HttpClientRequest.setHeader("Accept", "application/json, */*"), |
| 365 | } | 384 | ); |
| 366 | const response = yield* client.execute(request).pipe( | 385 | for (const [name, value] of Object.entries(credentials?.headers ?? {})) { |
| 367 | Effect.mapError( | 386 | request = HttpClientRequest.setHeader(request, name, value); |
| 368 | () => | 387 | } |
| 369 | new OpenApiParseError({ | 388 | const response = yield* client |
| 370 | message: "Failed to fetch Google Discovery document", | 389 | .execute(request) |
| 371 | }), | 390 | .pipe( |
| 372 | ), | 391 | Effect.mapError( |
| 373 | ); | 392 | () => new OpenApiParseError({ message: "Failed to fetch Google Discovery document" }), |
| 374 | if (response.status < 200 || response.status >= 300) { | 393 | ), |
| 375 | return yield* new OpenApiParseError({ | 394 | ); |
| 376 | message: `Failed to fetch Google Discovery document: HTTP ${response.status}`, | 395 | if (response.status < 200 || response.status >= 300) { |
| 396 | return yield* new OpenApiParseError({ | ||
| 397 | message: `Failed to fetch Google Discovery document: HTTP ${response.status}`, | ||
| 398 | }); | ||
| 399 | } | ||
| 400 | return yield* response.text.pipe( | ||
| 401 | Effect.mapError( | ||
| 402 | () => | ||
| 403 | new OpenApiParseError({ message: "Failed to read Google Discovery document body" }), | ||
| 404 | ), | ||
| 405 | ); | ||
| 377 | }); | 406 | }); |
| 378 | } | 407 | |
| 379 | return yield* response.text.pipe( | 408 | // Normalization maps a service-hosted URL onto the central directory for a |
| 380 | Effect.mapError( | 409 | // STABLE IDENTITY, but the directory does not list every service — Google |
| 381 | () => | 410 | // Ads answers only on googleads.googleapis.com, so the canonical form 404s |
| 382 | new OpenApiParseError({ | 411 | // for a URL the user pasted that works. Fall back to the host they named |
| 383 | message: "Failed to read Google Discovery document body", | 412 | // rather than maintaining an allowlist of every such service forever. |
| 384 | }), | 413 | const serviceHosted = serviceHostedDiscoveryUrl(discoveryUrl); |
| 414 | return yield* attempt(normalizedDiscoveryUrl).pipe( | ||
| 415 | Effect.catch((error) => | ||
| 416 | serviceHosted && serviceHosted !== normalizedDiscoveryUrl | ||
| 417 | ? attempt(serviceHosted) | ||
| 418 | : Effect.fail(error), | ||
| 385 | ), | 419 | ), |
| 386 | ); | 420 | ); |
| 387 | }, | 421 | }, |
| @@ -346,6 +346,7 @@ export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets · 345 unmodified lines | |||
| 346 | ...(preset.featured ? { featured: preset.featured } : {}), | 346 | ...(preset.featured ? { featured: preset.featured } : {}), |
| 347 | family: "google", | 347 | family: "google", |
| 348 | specFormat: "google-discovery", | 348 | specFormat: "google-discovery", |
| 349 | registryListed: true, | ||
| 349 | defaultSlug: googleServiceSlug(preset.id), | 350 | defaultSlug: googleServiceSlug(preset.id), |
| 350 | authTemplate: googleCatalogAuthTemplate(preset.id), | 351 | authTemplate: googleCatalogAuthTemplate(preset.id), |
| 351 | ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}), | 352 | ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}), |
| @@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab · 3 unmodified lines | |||
| 4 | import { createExecutor, IntegrationSlug } from "@executor-js/sdk"; | 4 | import { createExecutor, IntegrationSlug } from "@executor-js/sdk"; |
| 5 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | 5 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 6 | import { openApiPlugin, parse } from "@executor-js/plugin-openapi"; | 6 | import { openApiPlugin, parse } from "@executor-js/plugin-openapi"; |
| 7 | import { resolveSpecFormatAdapter } from "../../sdk/spec-format"; | ||
| 7 | import type { AuthenticationInput } from "@executor-js/plugin-openapi"; | 8 | import type { AuthenticationInput } from "@executor-js/plugin-openapi"; |
| 8 | 9 | ||
| 9 | import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; | 10 | import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; |
| @@ -290,3 +291,30 @@ it.effect("preserves a Google preset's full consumer consent boundary when refre · 280 unmodified lines | |||
| 290 | ); | 291 | ); |
| 291 | }), | 292 | }), |
| 292 | ); | 293 | ); |
| 294 | |||
| 295 | it.effect("a hand-pasted Discovery URL selects the adapter without a preset", () => | ||
| 296 | Effect.gen(function* () { | ||
| 297 | const detected = yield* resolveSpecFormatAdapter( | ||
| 298 | [googleDiscoveryAdapter], | ||
| 299 | undefined, | ||
| 300 | GMAIL_URL, | ||
| 301 | ); | ||
| 302 | expect(detected?.id).toBe("google-discovery"); | ||
| 303 | |||
| 304 | const plainOpenApi = yield* resolveSpecFormatAdapter( | ||
| 305 | [googleDiscoveryAdapter], | ||
| 306 | undefined, | ||
| 307 | "https://api.example.com/openapi.json", | ||
| 308 | ); | ||
| 309 | expect(plainOpenApi).toBeNull(); | ||
| 310 | |||
| 311 | // An explicit format id still wins over detection, and an unknown one | ||
| 312 | // still fails rather than silently falling back. | ||
| 313 | const explicit = yield* resolveSpecFormatAdapter( | ||
| 314 | [googleDiscoveryAdapter], | ||
| 315 | "google-discovery", | ||
| 316 | "https://api.example.com/openapi.json", | ||
| 317 | ); | ||
| 318 | expect(explicit?.id).toBe("google-discovery"); | ||
| 319 | }), | ||
| 320 | ); | ||
| @@ -6,6 +6,7 @@ import { type DerivedIdentity, type SpecFormatAdapter } from "../../sdk/spec-for · 5 unmodified lines | |||
| 6 | import { | 6 | import { |
| 7 | convertGoogleDiscoveryBundleToOpenApi, | 7 | convertGoogleDiscoveryBundleToOpenApi, |
| 8 | fetchGoogleDiscoveryDocument, | 8 | fetchGoogleDiscoveryDocument, |
| 9 | isGoogleDiscoveryUrl, | ||
| 9 | normalizeGoogleDiscoveryUrl, | 10 | normalizeGoogleDiscoveryUrl, |
| 10 | } from "./discovery"; | 11 | } from "./discovery"; |
| 11 | 12 | ||
| @@ -47,6 +48,7 @@ export const deriveGoogleDiscoveryIdentity = (doc: unknown): DerivedIdentity | n · 35 unmodified lines | |||
| 47 | 48 | ||
| 48 | export const googleDiscoveryAdapter: SpecFormatAdapter = { | 49 | export const googleDiscoveryAdapter: SpecFormatAdapter = { |
| 49 | id: "google-discovery", | 50 | id: "google-discovery", |
| 51 | detectsUrl: isGoogleDiscoveryUrl, | ||
| 50 | fetch: (input) => | 52 | fetch: (input) => |
| 51 | Effect.gen(function* () { | 53 | Effect.gen(function* () { |
| 52 | const documents = yield* Effect.forEach( | 54 | const documents = yield* Effect.forEach( |
| @@ -569,6 +569,7 @@ export const microsoftCatalog: readonly IntegrationPreset[] = microsoftGraphScop · 568 unmodified lines | |||
| 569 | ...(preset.featured ? { featured: preset.featured } : {}), | 569 | ...(preset.featured ? { featured: preset.featured } : {}), |
| 570 | family: "microsoft", | 570 | family: "microsoft", |
| 571 | specFormat: "microsoft-graph", | 571 | specFormat: "microsoft-graph", |
| 572 | registryListed: true, | ||
| 572 | defaultSlug: microsoftServiceSlug(preset.id), | 573 | defaultSlug: microsoftServiceSlug(preset.id), |
| 573 | authTemplate: microsoftGraphCatalogAuthTemplate(preset), | 574 | authTemplate: microsoftGraphCatalogAuthTemplate(preset), |
| 574 | ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}), | 575 | ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}), |
| @@ -221,3 +221,22 @@ it.effect("stream-previews a Graph selection without a whole-document parse", () · 220 unmodified lines | |||
| 221 | ]); | 221 | ]); |
| 222 | }), | 222 | }), |
| 223 | ); | 223 | ); |
| 224 | |||
| 225 | it("detects Graph sources so a hand-pasted URL does not fall to the plain path", () => { | ||
| 226 | const detects = (url: string) => microsoftGraphAdapter.detectsUrl?.(url) === true; | ||
| 227 | // The monolith is the case that matters: on the plain path it is 43 MB and | ||
| 228 | // trips the whole-document parse limit. | ||
| 229 | expect(detects(`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=mail`)).toBe(true); | ||
| 230 | expect(detects(MICROSOFT_GRAPH_OPENAPI_URL)).toBe(true); | ||
| 231 | expect( | ||
| 232 | detects( | ||
| 233 | "https://github.com/UsefulSoftwareCo/executor/releases/download/graph-slices/mail.yaml", | ||
| 234 | ), | ||
| 235 | ).toBe(true); | ||
| 236 | // Already-sliced hosted specs are self-contained and parse on the plain | ||
| 237 | // path; claiming them would route them through the slice builder for nothing. | ||
| 238 | expect(detects("https://integrations.sh/specs/microsoft-graph/mail.json")).toBe(false); | ||
| 239 | expect( | ||
| 240 | detects("https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json"), | ||
| 241 | ).toBe(false); | ||
| 242 | }); | ||
| @@ -3,6 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines | |||
| 3 | import type { SpecFormatAdapter } from "../../sdk/spec-format"; | 3 | import type { SpecFormatAdapter } from "../../sdk/spec-format"; |
| 4 | 4 | ||
| 5 | import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph"; | 5 | import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph"; |
| 6 | import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets"; | ||
| 6 | import { microsoftGraphPresetIdsForSliceAsset, microsoftGraphSliceAssetFromUrl } from "./slices"; | 7 | import { microsoftGraphPresetIdsForSliceAsset, microsoftGraphSliceAssetFromUrl } from "./slices"; |
| 7 | 8 | ||
| 8 | const fragmentPresetIds = (hash: string): readonly string[] => | 9 | const fragmentPresetIds = (hash: string): readonly string[] => |
| @@ -40,8 +41,28 @@ const graphCatalogSelection = ( · 31 unmodified lines | |||
| 40 | }; | 41 | }; |
| 41 | }; | 42 | }; |
| 42 | 43 | ||
| 44 | /** Recognizes the Graph sources this adapter exists to handle: Microsoft's | ||
| 45 | * published monolith and executor's slice assets, with or without a | ||
| 46 | * `#preset=` selector. Without this, pasting one of those URLs by hand falls | ||
| 47 | * to the plain OpenAPI path, which then chokes on the 43 MB monolith — the | ||
| 48 | * adapter only ever engaged when a preset supplied `specFormat`. */ | ||
| 49 | const detectsGraphUrl = (url: string): boolean => { | ||
| 50 | if (!URL.canParse(url)) return false; | ||
| 51 | const parsed = new URL(url); | ||
| 52 | parsed.hash = ""; | ||
| 53 | const bare = parsed.toString(); | ||
| 54 | return ( | ||
| 55 | bare === MICROSOFT_GRAPH_OPENAPI_URL || | ||
| 56 | microsoftGraphSliceAssetFromUrl(bare) !== null || | ||
| 57 | /^https:\/\/raw\.githubusercontent\.com\/microsoftgraph\/msgraph-metadata\/.*\/openapi\.ya?ml$/.test( | ||
| 58 | bare, | ||
| 59 | ) | ||
| 60 | ); | ||
| 61 | }; | ||
| 62 | |||
| 43 | export const microsoftGraphAdapter: SpecFormatAdapter = { | 63 | export const microsoftGraphAdapter: SpecFormatAdapter = { |
| 44 | id: "microsoft-graph", | 64 | id: "microsoft-graph", |
| 65 | detectsUrl: detectsGraphUrl, | ||
| 45 | fetch: (input) => | 66 | fetch: (input) => |
| 46 | buildMicrosoftGraphOpenApiSpec( | 67 | buildMicrosoftGraphOpenApiSpec( |
| 47 | graphCatalogSelection(input.urls[0]), | 68 | graphCatalogSelection(input.urls[0]), |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | |||
| 25 | type AuthMethodRow, | 25 | type AuthMethodRow, |
| 26 | type AuthMethodSeed, | 26 | type AuthMethodSeed, |
| 27 | } from "@executor-js/react/components/auth-method-list-editor"; | 27 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 28 | import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | ||
| 28 | import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; | 29 | import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; |
| 29 | import { FieldLabel } from "@executor-js/react/components/field"; | 30 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 30 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 31 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| @@ -170,18 +171,40 @@ export default function AddOpenApiIntegration(props: { · 139 unmodified lines | |||
| 170 | initialUrl?: string; | 171 | initialUrl?: string; |
| 171 | initialPreset?: string; | 172 | initialPreset?: string; |
| 172 | initialNamespace?: string; | 173 | initialNamespace?: string; |
| 174 | initialAuthHeader?: string; | ||
| 175 | initialAuthNote?: string; | ||
| 176 | initialSpecOverrides?: string; | ||
| 173 | }) { | 177 | }) { |
| 174 | const integrationPlugins = useIntegrationPlugins(); | 178 | const integrationPlugins = useIntegrationPlugins(); |
| 175 | const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi"); | 179 | const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi"); |
| 176 | const openApiPresets = openApiPlugin?.presets; | 180 | const openApiPresets = openApiPlugin?.presets; |
| 177 | const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); | 181 | // A `?preset=` deep link is self-sufficient: the preset's own URL seeds the |
| 182 | // field. The connect dialog used to pass `&url=` alongside, which hid that | ||
| 183 | // this never worked on its own — preset links from agents and docs carry | ||
| 184 | // only the id. | ||
| 185 | const [specUrl, setSpecUrl] = useState( | ||
| 186 | () => | ||
| 187 | props.initialUrl ?? | ||
| 188 | openApiPresets?.find((preset) => preset.id === props.initialPreset)?.url ?? | ||
| 189 | "", | ||
| 190 | ); | ||
| 178 | const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null); | 191 | const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null); |
| 179 | const [analyzing, setAnalyzing] = useState(false); | 192 | const [analyzing, setAnalyzing] = useState(false); |
| 180 | const [analyzeError, setAnalyzeError] = useState<string | null>(null); | 193 | const [analyzeError, setAnalyzeError] = useState<string | null>(null); |
| 181 | 194 | ||
| 182 | const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl); | 195 | const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl); |
| 183 | const presetSpecOverrides = decodeOpenApiSpecOverrides(activePreset?.specOverrides); | 196 | const presetSpecOverrides = decodeOpenApiSpecOverrides(activePreset?.specOverrides); |
| 184 | const specOverridesText = specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides); | 197 | // Registry-declared overrides — how a vendor's published document gets |
| 198 | // improved without hosting a fork (Neon's console session cookies posing as | ||
| 199 | // security schemes). A preset's own overrides win; the user's draft wins | ||
| 200 | // over both, and the editor shows exactly what will apply. | ||
| 201 | const registrySpecOverrides = useMemo(() => { | ||
| 202 | if (!props.initialSpecOverrides) return undefined; | ||
| 203 | const parsed = parseSpecOverridesText(props.initialSpecOverrides); | ||
| 204 | return parsed.ok ? parsed.value : undefined; | ||
| 205 | }, [props.initialSpecOverrides]); | ||
| 206 | const specOverridesText = | ||
| 207 | specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides ?? registrySpecOverrides); | ||
| 185 | 208 | ||
| 186 | // After analysis | 209 | // After analysis |
| 187 | const [preview, setPreview] = useState<SpecPreviewSummary | null>(null); | 210 | const [preview, setPreview] = useState<SpecPreviewSummary | null>(null); |
| @@ -297,14 +320,26 @@ export default function AddOpenApiIntegration(props: { · 109 unmodified lines | |||
| 297 | ...(preview?.headerPresets ?? []).map((preset) => preset.label), | 320 | ...(preview?.headerPresets ?? []).map((preset) => preset.label), |
| 298 | ...(preview?.oauth2Presets ?? []).map((preset) => preset.label), | 321 | ...(preview?.oauth2Presets ?? []).map((preset) => preset.label), |
| 299 | ]; | 322 | ]; |
| 300 | return authenticationTemplate.map( | 323 | const detected = authenticationTemplate.map( |
| 301 | (template: Authentication, index: number): AuthMethodSeed => ({ | 324 | (template: Authentication, index: number): AuthMethodSeed => ({ |
| 302 | value: editorValueFromAuthentication(template), | 325 | value: editorValueFromAuthentication(template), |
| 303 | slug: String(template.slug), | 326 | slug: String(template.slug), |
| 304 | ...(labels[index] !== undefined ? { label: labels[index] } : {}), | 327 | ...(labels[index] !== undefined ? { label: labels[index] } : {}), |
| 305 | }), | 328 | }), |
| 306 | ); | 329 | ); |
| 307 | }, [preview, authenticationTemplate]); | 330 | if (preview === null) return detected; |
| 331 | // The registry's declared credential placement fills what the document | ||
| 332 | // leaves out. GitHub's official description famously declares no | ||
| 333 | // securitySchemes at all, and even with the OAuth preset template a PAT | ||
| 334 | // bearer header is how most calls actually authenticate — so the key | ||
| 335 | // method is offered alongside OAuth. A spec-declared key method wins over | ||
| 336 | // the registry's version of the same fact. | ||
| 337 | const placement = props.initialAuthHeader | ||
| 338 | ? placementFromHeaderPattern(props.initialAuthHeader) | ||
| 339 | : null; | ||
| 340 | if (!placement || detected.some((seed) => seed.value.kind === "apikey")) return detected; | ||
| 341 | return [...detected, { value: { kind: "apikey", placements: [placement] } }]; | ||
| 342 | }, [preview, authenticationTemplate, props.initialAuthHeader]); | ||
| 308 | const authMethodList = useAuthMethodList(authMethodSeeds); | 343 | const authMethodList = useAuthMethodList(authMethodSeeds); |
| 309 | 344 | ||
| 310 | // The methods to register, mapped back to stored `Authentication[]`. Drops | 345 | // The methods to register, mapped back to stored `Authentication[]`. Drops |
| @@ -597,10 +632,14 @@ export default function AddOpenApiIntegration(props: { · 286 unmodified lines | |||
| 597 | <AuthMethodListEditor | 632 | <AuthMethodListEditor |
| 598 | list={authMethodList} | 633 | list={authMethodList} |
| 599 | emptyHint="No authentication detected. Add a method, or add the integration without auth and connect an account from the integration page later." | 634 | emptyHint="No authentication detected. Add a method, or add the integration without auth and connect an account from the integration page later." |
| 600 | footerHint="Every method here is registered with the integration. Connect an account from the integration page after adding." | 635 | footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page." |
| 601 | /> | 636 | /> |
| 602 | )} | 637 | )} |
| 603 | 638 | ||
| 639 | {preview && props.initialAuthNote ? ( | ||
| 640 | <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p> | ||
| 641 | ) : null} | ||
| 642 | |||
| 604 | {preview ? ( | 643 | {preview ? ( |
| 605 | <AddOpenApiHealthCheckSection | 644 | <AddOpenApiHealthCheckSection |
| 606 | presetHealthCheck={activePreset?.healthCheck} | 645 | presetHealthCheck={activePreset?.healthCheck} |
| @@ -0,0 +1,241 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Option } from "effect"; | ||
| 3 | |||
| 4 | import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides"; | ||
| 5 | import { Exit } from "effect"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | composeQuickAddAuth, | ||
| 9 | performQuickAdd, | ||
| 10 | quickAddRequestPayloads, | ||
| 11 | quickAddSpecPlan, | ||
| 12 | type QuickAddDeps, | ||
| 13 | } from "./integration-plugin"; | ||
| 14 | |||
| 15 | // The quick add's composition is where review found bugs twice: first it | ||
| 16 | // suppressed spec-declared methods, then it derived auth from the unmodified | ||
| 17 | // document while adding the overridden one. These pin the contract. | ||
| 18 | |||
| 19 | const scopeOverride = decodeOpenApiSpecOverrides([ | ||
| 20 | { | ||
| 21 | op: "replace", | ||
| 22 | path: "/components/securitySchemes/OAuth2/flows/authorizationCode/scopes", | ||
| 23 | value: { "files:read": "" }, | ||
| 24 | }, | ||
| 25 | ])!; | ||
| 26 | |||
| 27 | describe("quickAddSpecPlan", () => { | ||
| 28 | it("uses the same effective overrides for preview and add, preset first", () => { | ||
| 29 | const registryOverrides = decodeOpenApiSpecOverrides([ | ||
| 30 | { op: "remove", path: "/components/securitySchemes/Cookie" }, | ||
| 31 | ]); | ||
| 32 | const plan = quickAddSpecPlan( | ||
| 33 | { | ||
| 34 | id: "figma", | ||
| 35 | name: "Figma", | ||
| 36 | summary: "", | ||
| 37 | specFormat: "plain-ish", | ||
| 38 | specOverrides: [...scopeOverride], | ||
| 39 | }, | ||
| 40 | registryOverrides, | ||
| 41 | ); | ||
| 42 | // Preset overrides win over the registry's, mirroring the full add page. | ||
| 43 | expect(plan.specOverrides).toEqual(scopeOverride); | ||
| 44 | expect(plan.specFormat).toBe("plain-ish"); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("an explicitly EMPTY preset override list suppresses the registry's", () => { | ||
| 48 | // Presence-based, like the full page's `presetOverrides ?? registry`: | ||
| 49 | // an empty list is a decision, not an absence. | ||
| 50 | const registryOverrides = decodeOpenApiSpecOverrides([ | ||
| 51 | { op: "remove", path: "/components/securitySchemes/Cookie" }, | ||
| 52 | ]); | ||
| 53 | const plan = quickAddSpecPlan( | ||
| 54 | { id: "custom", name: "Custom", summary: "", specOverrides: [] }, | ||
| 55 | registryOverrides, | ||
| 56 | ); | ||
| 57 | expect(plan.specOverrides).toBeUndefined(); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("falls through to registry overrides when the preset has none", () => { | ||
| 61 | const registryOverrides = decodeOpenApiSpecOverrides([ | ||
| 62 | { op: "remove", path: "/components/securitySchemes/Cookie" }, | ||
| 63 | ]); | ||
| 64 | const plan = quickAddSpecPlan(undefined, registryOverrides); | ||
| 65 | expect(plan.specOverrides).toEqual(registryOverrides); | ||
| 66 | expect(plan.specFormat).toBeUndefined(); | ||
| 67 | }); | ||
| 68 | }); | ||
| 69 | |||
| 70 | describe("composeQuickAddAuth", () => { | ||
| 71 | const registryPlacement = { | ||
| 72 | carrier: "header", | ||
| 73 | name: "Authorization", | ||
| 74 | prefix: "Bearer ", | ||
| 75 | } as const; | ||
| 76 | |||
| 77 | it("keeps override-derived OAuth scopes and appends the registry key", () => { | ||
| 78 | // The preview reflects the OVERRIDDEN document (quickAddSpecPlan sends | ||
| 79 | // the same overrides to preview): its oauth preset carries the replaced | ||
| 80 | // scopes. The composed template must retain them verbatim and add the | ||
| 81 | // registry header only because no key method was detected. | ||
| 82 | const template = composeQuickAddAuth([], registryPlacement, { | ||
| 83 | headerPresets: [], | ||
| 84 | oauth2Presets: [ | ||
| 85 | { | ||
| 86 | label: "OAuth2", | ||
| 87 | securitySchemeName: "OAuth2", | ||
| 88 | flow: "authorizationCode" as const, | ||
| 89 | authorizationUrl: Option.some("https://example.com/authorize"), | ||
| 90 | tokenUrl: "https://example.com/token", | ||
| 91 | resource: Option.none(), | ||
| 92 | refreshUrl: Option.none(), | ||
| 93 | // The overridden document's scopes — the whole point: the preview | ||
| 94 | // reflects the SAME effective spec the add stores. | ||
| 95 | scopes: { "files:read": "" }, | ||
| 96 | identityScopes: [] as const, | ||
| 97 | }, | ||
| 98 | ], | ||
| 99 | servers: [{ url: "https://api.example.com" }], | ||
| 100 | }); | ||
| 101 | expect(template).toHaveLength(2); | ||
| 102 | const oauth = template.find((method) => "kind" in method && method.kind === "oauth2"); | ||
| 103 | expect(oauth && "scopes" in oauth ? oauth.scopes : undefined).toEqual(["files:read"]); | ||
| 104 | const key = template.find((method) => !("kind" in method) || method.kind !== "oauth2"); | ||
| 105 | expect(JSON.stringify(key)).toContain("Authorization"); | ||
| 106 | }); | ||
| 107 | |||
| 108 | it("does not append the registry key when the spec already declares one", () => { | ||
| 109 | const template = composeQuickAddAuth([], registryPlacement, { | ||
| 110 | headerPresets: [ | ||
| 111 | { | ||
| 112 | label: "API key", | ||
| 113 | headers: { "X-API-Key": null }, | ||
| 114 | secretHeaders: ["X-API-Key"], | ||
| 115 | secretQueryParams: [], | ||
| 116 | }, | ||
| 117 | ], | ||
| 118 | oauth2Presets: [], | ||
| 119 | servers: [{ url: "https://api.example.com" }], | ||
| 120 | }); | ||
| 121 | expect(template).toHaveLength(1); | ||
| 122 | expect(JSON.stringify(template[0])).toContain("X-API-Key"); | ||
| 123 | }); | ||
| 124 | }); | ||
| 125 | |||
| 126 | describe("quickAddRequestPayloads", () => { | ||
| 127 | it("the preview and the add carry the identical spec plan", () => { | ||
| 128 | // The regression contract itself: reverting either call site to a bare | ||
| 129 | // payload makes these fields diverge and this test fail. | ||
| 130 | const plan = quickAddSpecPlan( | ||
| 131 | { | ||
| 132 | id: "figma", | ||
| 133 | name: "Figma", | ||
| 134 | summary: "", | ||
| 135 | specFormat: "fmt", | ||
| 136 | specOverrides: [...scopeOverride], | ||
| 137 | }, | ||
| 138 | undefined, | ||
| 139 | ); | ||
| 140 | const requests = quickAddRequestPayloads( | ||
| 141 | { url: "https://example.com/spec.yaml", name: "Figma API", domain: "figma.com" }, | ||
| 142 | "figma_api", | ||
| 143 | plan, | ||
| 144 | ); | ||
| 145 | expect(requests.preview.specFormat).toBe("fmt"); | ||
| 146 | expect(requests.preview.specOverrides).toEqual(scopeOverride); | ||
| 147 | expect(requests.add.specFormat).toBe(requests.preview.specFormat); | ||
| 148 | expect(requests.add.specOverrides).toEqual(requests.preview.specOverrides); | ||
| 149 | expect(requests.add.displayDomain).toBe("figma.com"); | ||
| 150 | expect(requests.add.spec).toEqual({ kind: "url", url: "https://example.com/spec.yaml" }); | ||
| 151 | }); | ||
| 152 | }); | ||
| 153 | |||
| 154 | describe("performQuickAdd", () => { | ||
| 155 | // The OPERATION under test, with the two mutations captured — the guard | ||
| 156 | // the review demanded twice: helper tests kept passing while a call site | ||
| 157 | // could quietly stop using the shared plan. | ||
| 158 | it("sends the identical spec plan to preview and add, and composed auth", async () => { | ||
| 159 | const previews: unknown[] = []; | ||
| 160 | const adds: unknown[] = []; | ||
| 161 | const deps: QuickAddDeps = { | ||
| 162 | presets: [ | ||
| 163 | { | ||
| 164 | id: "figma", | ||
| 165 | name: "Figma", | ||
| 166 | summary: "", | ||
| 167 | url: "https://example.com/spec.yaml", | ||
| 168 | specFormat: "fmt", | ||
| 169 | specOverrides: [...scopeOverride], | ||
| 170 | }, | ||
| 171 | ], | ||
| 172 | preview: (payload) => { | ||
| 173 | previews.push(payload); | ||
| 174 | return Promise.resolve( | ||
| 175 | Exit.succeed({ | ||
| 176 | headerPresets: [], | ||
| 177 | oauth2Presets: [ | ||
| 178 | { | ||
| 179 | label: "OAuth2", | ||
| 180 | securitySchemeName: "OAuth2", | ||
| 181 | flow: "authorizationCode" as const, | ||
| 182 | authorizationUrl: Option.some("https://example.com/authorize"), | ||
| 183 | tokenUrl: "https://example.com/token", | ||
| 184 | resource: Option.none(), | ||
| 185 | refreshUrl: Option.none(), | ||
| 186 | scopes: { "files:read": "" }, | ||
| 187 | identityScopes: [] as const, | ||
| 188 | }, | ||
| 189 | ], | ||
| 190 | servers: [{ url: "https://api.example.com" }], | ||
| 191 | }), | ||
| 192 | ); | ||
| 193 | }, | ||
| 194 | add: (payload) => { | ||
| 195 | adds.push(payload); | ||
| 196 | return Promise.resolve(Exit.succeed({ slug: "figma_api" })); | ||
| 197 | }, | ||
| 198 | }; | ||
| 199 | const result = await performQuickAdd(deps, { | ||
| 200 | url: "https://example.com/spec.yaml", | ||
| 201 | name: "Figma API", | ||
| 202 | slug: "figma-api", | ||
| 203 | domain: "figma.com", | ||
| 204 | authHeader: "Authorization: Bearer {token}", | ||
| 205 | }); | ||
| 206 | expect(result).toEqual({ ok: true, slug: "figma_api" }); | ||
| 207 | expect(previews).toHaveLength(1); | ||
| 208 | expect(adds).toHaveLength(1); | ||
| 209 | const preview = previews[0] as Record<string, unknown>; | ||
| 210 | const add = adds[0] as Record<string, unknown>; | ||
| 211 | // The regression contract: BOTH real payloads carry the one spec plan. | ||
| 212 | expect(preview.specFormat).toBe("fmt"); | ||
| 213 | expect(preview.specOverrides).toEqual(scopeOverride); | ||
| 214 | expect(add.specFormat).toBe(preview.specFormat); | ||
| 215 | expect(add.specOverrides).toEqual(preview.specOverrides); | ||
| 216 | expect(add.slug).toBe("figma_api"); | ||
| 217 | expect(add.displayDomain).toBe("figma.com"); | ||
| 218 | // Auth derived from the overridden document, registry key appended. | ||
| 219 | const template = add.authenticationTemplate as readonly unknown[]; | ||
| 220 | expect(JSON.stringify(template)).toContain("files:read"); | ||
| 221 | expect(JSON.stringify(template)).toContain("Authorization"); | ||
| 222 | }); | ||
| 223 | |||
| 224 | it("skips the preview entirely when the registry declared no header", async () => { | ||
| 225 | const previews: unknown[] = []; | ||
| 226 | const deps: QuickAddDeps = { | ||
| 227 | presets: [], | ||
| 228 | preview: (payload) => { | ||
| 229 | previews.push(payload); | ||
| 230 | return Promise.resolve(Exit.fail("unexpected")); | ||
| 231 | }, | ||
| 232 | add: () => Promise.resolve(Exit.succeed({ slug: "plain" })), | ||
| 233 | }; | ||
| 234 | const result = await performQuickAdd(deps, { | ||
| 235 | url: "https://example.com/openapi.json", | ||
| 236 | name: "Plain API", | ||
| 237 | }); | ||
| 238 | expect(result).toEqual({ ok: true, slug: "plain" }); | ||
| 239 | expect(previews).toHaveLength(0); | ||
| 240 | }); | ||
| 241 | }); | ||
| @@ -1,6 +1,234 @@ | |||
| 1 | import { lazy } from "react"; | 1 | import { lazy, useCallback } from "react"; |
| 2 | import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import type { | ||
| 5 | IntegrationPlugin, | ||
| 6 | IntegrationPreset, | ||
| 7 | IntegrationQuickAddInput, | ||
| 8 | IntegrationQuickAddResult, | ||
| 9 | } from "@executor-js/sdk/client"; | ||
| 10 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | ||
| 11 | import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; | ||
| 12 | import { placementFromHeaderPattern, type Placement } from "@executor-js/react/lib/auth-placements"; | ||
| 13 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 3 | import { openApiPresets } from "../sdk/presets"; | 14 | import { openApiPresets } from "../sdk/presets"; |
| 15 | import { detectedAuthenticationTemplates } from "../sdk/derive-auth"; | ||
| 16 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; | ||
| 17 | import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config"; | ||
| 18 | import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides"; | ||
| 19 | |||
| 20 | const normalizedSpecUrl = (url: string): string => { | ||
| 21 | if (!URL.canParse(url)) return url.trim().replace(/\/$/, ""); | ||
| 22 | const parsed = new URL(url); | ||
| 23 | parsed.hash = ""; | ||
| 24 | parsed.searchParams.sort(); | ||
| 25 | return parsed.toString().replace(/\/$/, ""); | ||
| 26 | }; | ||
| 27 | |||
| 28 | /** The preset table still knows things no spec can say — GitHub's OAuth | ||
| 29 | * endpoints against a spec that declares NO security at all. A registry row | ||
| 30 | * whose URL is a preset's URL gets that knowledge pulled across. Matches the | ||
| 31 | * plugin's COMPLETE preset list, so a deployment's custom presets | ||
| 32 | * contribute too, not just the built-ins. */ | ||
| 33 | const presetForSpecUrl = ( | ||
| 34 | presets: readonly IntegrationPreset[], | ||
| 35 | url: string, | ||
| 36 | ): IntegrationPreset | undefined => { | ||
| 37 | const target = normalizedSpecUrl(url); | ||
| 38 | return presets.find( | ||
| 39 | (preset) => preset.url !== undefined && normalizedSpecUrl(preset.url) === target, | ||
| 40 | ); | ||
| 41 | }; | ||
| 42 | |||
| 43 | /** One-click add for a registry OpenAPI row. The spec itself is the | ||
| 44 | * configuration: omitting `authenticationTemplate` and `baseUrl` tells the | ||
| 45 | * server to derive both from the document, exactly what the add page's | ||
| 46 | * untouched defaults submit. Registry spec overrides ride along. */ | ||
| 47 | |||
| 48 | /** The spec inputs a quick add must hold CONSISTENT between preview and add: | ||
| 49 | * deriving auth from a document other than the one being added is how a | ||
| 50 | * preset's scope overrides got silently dropped. Preset overrides win over | ||
| 51 | * the registry's, mirroring the full add page. */ | ||
| 52 | export const quickAddSpecPlan = ( | ||
| 53 | preset: IntegrationPreset | undefined, | ||
| 54 | registryOverrides: ReturnType<typeof decodeOpenApiSpecOverrides>, | ||
| 55 | ): { | ||
| 56 | readonly specFormat?: string; | ||
| 57 | readonly specOverrides?: NonNullable<ReturnType<typeof decodeOpenApiSpecOverrides>>; | ||
| 58 | } => { | ||
| 59 | const presetOverrides = preset?.specOverrides | ||
| 60 | ? decodeOpenApiSpecOverrides(preset.specOverrides) | ||
| 61 | : undefined; | ||
| 62 | // PRESENCE-based, mirroring the full page's `presetOverrides ?? | ||
| 63 | // registryOverrides`: a preset declaring an explicitly EMPTY override list | ||
| 64 | // is a decision — suppress the registry's patches — not an absence. | ||
| 65 | const effective = presetOverrides !== undefined ? presetOverrides : registryOverrides; | ||
| 66 | return { | ||
| 67 | ...(preset?.specFormat ? { specFormat: preset.specFormat } : {}), | ||
| 68 | ...(effective && effective.length > 0 ? { specOverrides: effective } : {}), | ||
| 69 | }; | ||
| 70 | }; | ||
| 71 | |||
| 72 | /** The preview and add requests built from ONE plan, so they cannot diverge: | ||
| 73 | * deriving auth from a different effective document than the one stored is | ||
| 74 | * the bug this module has now had twice. The add payload takes further | ||
| 75 | * fields (family, health check, auth template) AFTER this base. */ | ||
| 76 | export const quickAddRequestPayloads = ( | ||
| 77 | input: { readonly url: string; readonly name: string; readonly domain?: string }, | ||
| 78 | slug: string, | ||
| 79 | specPlan: ReturnType<typeof quickAddSpecPlan>, | ||
| 80 | ): { | ||
| 81 | readonly preview: { readonly spec: string } & ReturnType<typeof quickAddSpecPlan>; | ||
| 82 | readonly add: { | ||
| 83 | readonly spec: { readonly kind: "url"; readonly url: string }; | ||
| 84 | readonly slug: string; | ||
| 85 | readonly name: string; | ||
| 86 | readonly displayDomain?: string; | ||
| 87 | } & ReturnType<typeof quickAddSpecPlan>; | ||
| 88 | } => ({ | ||
| 89 | preview: { spec: input.url, ...specPlan }, | ||
| 90 | add: { | ||
| 91 | spec: { kind: "url", url: input.url }, | ||
| 92 | slug, | ||
| 93 | name: input.name, | ||
| 94 | ...(input.domain ? { displayDomain: input.domain } : {}), | ||
| 95 | ...specPlan, | ||
| 96 | }, | ||
| 97 | }); | ||
| 98 | |||
| 99 | /** The full add page's method policy as one pure step: preset OAuth wins | ||
| 100 | * outright; else every preview-detected method is preserved and the | ||
| 101 | * registry's key placement is appended only when the detected set has no | ||
| 102 | * key method (GitHub declares no security at all). `preview` MUST be the | ||
| 103 | * summary of the same effective document the add will store. */ | ||
| 104 | export const composeQuickAddAuth = ( | ||
| 105 | presetMethods: readonly ReturnType<typeof openApiWireAuthInput>[], | ||
| 106 | registryPlacement: Placement | null, | ||
| 107 | preview: { | ||
| 108 | readonly headerPresets: Parameters<typeof detectedAuthenticationTemplates>[0]; | ||
| 109 | readonly oauth2Presets: Parameters<typeof detectedAuthenticationTemplates>[1]; | ||
| 110 | readonly servers: readonly { readonly url: string }[]; | ||
| 111 | } | null, | ||
| 112 | ): readonly ReturnType<typeof openApiWireAuthInput>[] => { | ||
| 113 | if (presetMethods.length > 0) { | ||
| 114 | return registryPlacement | ||
| 115 | ? [...presetMethods, openApiWireAuthInput(templateFromPlacements([registryPlacement]))] | ||
| 116 | : presetMethods; | ||
| 117 | } | ||
| 118 | if (!registryPlacement || preview === null) return presetMethods; | ||
| 119 | const detected = detectedAuthenticationTemplates( | ||
| 120 | preview.headerPresets, | ||
| 121 | preview.oauth2Presets, | ||
| 122 | preview.servers[0]?.url ?? "", | ||
| 123 | ); | ||
| 124 | const detectedHasApiKey = detected.some((template) => template.kind === "apikey"); | ||
| 125 | return [ | ||
| 126 | ...detected.map(openApiWireAuthInput), | ||
| 127 | ...(detectedHasApiKey | ||
| 128 | ? [] | ||
| 129 | : [openApiWireAuthInput(templateFromPlacements([registryPlacement]))]), | ||
| 130 | ]; | ||
| 131 | }; | ||
| 132 | |||
| 133 | export interface QuickAddDeps { | ||
| 134 | readonly presets: readonly IntegrationPreset[]; | ||
| 135 | /** The two mutations, injected so the OPERATION is testable against its | ||
| 136 | * actual outgoing payloads — helper-level tests kept passing while a call | ||
| 137 | * site quietly stopped using the shared plan. */ | ||
| 138 | readonly preview: ( | ||
| 139 | payload: ReturnType<typeof quickAddRequestPayloads>["preview"], | ||
| 140 | ) => Promise<Exit.Exit<NonNullable<Parameters<typeof composeQuickAddAuth>[2]>, unknown>>; | ||
| 141 | readonly add: ( | ||
| 142 | payload: ReturnType<typeof quickAddRequestPayloads>["add"] & { | ||
| 143 | readonly family?: string; | ||
| 144 | readonly healthCheck?: IntegrationPreset["healthCheck"]; | ||
| 145 | readonly authenticationTemplate?: ReturnType<typeof openApiWireAuthInput>[]; | ||
| 146 | }, | ||
| 147 | ) => Promise<Exit.Exit<{ readonly slug: string }, unknown>>; | ||
| 148 | } | ||
| 149 | |||
| 150 | /** The complete quick-add operation, pure of React: everything between the | ||
| 151 | * picker's click and the two API calls. */ | ||
| 152 | export const performQuickAdd = async ( | ||
| 153 | deps: QuickAddDeps, | ||
| 154 | input: IntegrationQuickAddInput, | ||
| 155 | ): Promise<IntegrationQuickAddResult> => { | ||
| 156 | const { presets } = deps; | ||
| 157 | const slug = slugifyNamespace(input.slug ?? input.name); | ||
| 158 | if (!slug) return { ok: false, reason: "no derivable slug" }; | ||
| 159 | // Registry overrides arrive as untyped JSON; a malformed patch goes to | ||
| 160 | // the configuration screen (its editor renders the parse error) rather | ||
| 161 | // than being silently dropped from a "successful" quick add. | ||
| 162 | const specOverrides = input.specOverrides | ||
| 163 | ? decodeOpenApiSpecOverrides(input.specOverrides) | ||
| 164 | : undefined; | ||
| 165 | if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) { | ||
| 166 | return { ok: false, reason: "unparseable spec overrides" }; | ||
| 167 | } | ||
| 168 | const preset = presetForSpecUrl(presets, input.url); | ||
| 169 | const registryPlacement = input.authHeader ? placementFromHeaderPattern(input.authHeader) : null; | ||
| 170 | const presetMethods = (preset?.authTemplate ?? []).flatMap((template) => | ||
| 171 | template.kind === "oauth2" | ||
| 172 | ? [ | ||
| 173 | openApiWireAuthInput({ | ||
| 174 | ...template, | ||
| 175 | slug: AuthTemplateSlug.make(template.slug), | ||
| 176 | resource: template.resource ?? undefined, | ||
| 177 | }), | ||
| 178 | ] | ||
| 179 | : [], | ||
| 180 | ); | ||
| 181 | // ONE spec plan for both preview and add: deriving auth from a | ||
| 182 | // different effective document than the one stored is how a preset's | ||
| 183 | // scope overrides got silently dropped. | ||
| 184 | const specPlan = quickAddSpecPlan(preset, specOverrides); | ||
| 185 | const requests = quickAddRequestPayloads( | ||
| 186 | { url: input.url, name: input.name, ...(input.domain ? { domain: input.domain } : {}) }, | ||
| 187 | slug, | ||
| 188 | specPlan, | ||
| 189 | ); | ||
| 190 | let preview: Parameters<typeof composeQuickAddAuth>[2] = null; | ||
| 191 | if (presetMethods.length === 0 && registryPlacement) { | ||
| 192 | // Composing with spec knowledge needs the spec: one preview call, | ||
| 193 | // only on this path (a plain registry row with no auth facts still | ||
| 194 | // adds with zero extra round trips). | ||
| 195 | const previewExit = await deps.preview(requests.preview); | ||
| 196 | if (Exit.isFailure(previewExit)) return { ok: false, reason: "preview failed" }; | ||
| 197 | preview = previewExit.value; | ||
| 198 | } | ||
| 199 | const authenticationTemplate = composeQuickAddAuth(presetMethods, registryPlacement, preview); | ||
| 200 | const exit = await deps.add({ | ||
| 201 | ...requests.add, | ||
| 202 | ...(preset?.family ? { family: preset.family } : {}), | ||
| 203 | ...(preset?.healthCheck ? { healthCheck: preset.healthCheck } : {}), | ||
| 204 | ...(authenticationTemplate.length > 0 | ||
| 205 | ? { authenticationTemplate: [...authenticationTemplate] } | ||
| 206 | : {}), | ||
| 207 | }); | ||
| 208 | if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" }; | ||
| 209 | return { ok: true, slug: String(exit.value.slug) }; | ||
| 210 | }; | ||
| 211 | |||
| 212 | function makeUseQuickAdd(presets: readonly IntegrationPreset[]) { | ||
| 213 | return function useOpenApiQuickAdd(): ( | ||
| 214 | input: IntegrationQuickAddInput, | ||
| 215 | ) => Promise<IntegrationQuickAddResult> { | ||
| 216 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); | ||
| 217 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); | ||
| 218 | return useCallback( | ||
| 219 | (input) => | ||
| 220 | performQuickAdd( | ||
| 221 | { | ||
| 222 | presets, | ||
| 223 | preview: (payload) => doPreview({ payload }), | ||
| 224 | add: (payload) => doAdd({ payload, reactivityKeys: integrationWriteKeys }), | ||
| 225 | }, | ||
| 226 | input, | ||
| 227 | ), | ||
| 228 | [doAdd, doPreview], | ||
| 229 | ); | ||
| 230 | }; | ||
| 231 | } | ||
| 4 | 232 | ||
| 5 | const importAdd = () => import("./AddOpenApiIntegration"); | 233 | const importAdd = () => import("./AddOpenApiIntegration"); |
| 6 | const importEditSheet = () => import("./UpdateSpecSection"); | 234 | const importEditSheet = () => import("./UpdateSpecSection"); |
| @@ -10,20 +238,27 @@ export interface OpenApiClientConfig { · 3 unmodified lines | |||
| 10 | readonly presets?: readonly IntegrationPreset[]; | 238 | readonly presets?: readonly IntegrationPreset[]; |
| 11 | } | 239 | } |
| 12 | 240 | ||
| 13 | export const createOpenApiIntegrationPlugin = ( | 241 | export const createOpenApiIntegrationPlugin = (config?: OpenApiClientConfig): IntegrationPlugin => { |
| 14 | config?: OpenApiClientConfig, | 242 | // Built-ins are registry-listed (the picker shows the registry's card); |
| 15 | ): IntegrationPlugin => ({ | 243 | // a deployment's custom presets are not, and keep their own cards. |
| 16 | key: "openapi", | 244 | const presets: readonly IntegrationPreset[] = [ |
| 17 | label: "OpenAPI", | 245 | ...openApiPresets.map((preset) => ({ ...preset, registryListed: true })), |
| 18 | add: lazy(importAdd), | 246 | ...(config?.presets ?? []), |
| 19 | editSheet: lazy(importEditSheet), | 247 | ]; |
| 20 | accounts: lazy(importAccounts), | 248 | return { |
| 21 | presets: [...openApiPresets, ...(config?.presets ?? [])], | 249 | key: "openapi", |
| 22 | preload: () => { | 250 | label: "OpenAPI", |
| 23 | void importAdd(); | 251 | add: lazy(importAdd), |
| 24 | void importEditSheet(); | 252 | editSheet: lazy(importEditSheet), |
| 25 | void importAccounts(); | 253 | accounts: lazy(importAccounts), |
| 26 | }, | 254 | presets, |
| 27 | }); | 255 | preload: () => { |
| 256 | void importAdd(); | ||
| 257 | void importEditSheet(); | ||
| 258 | void importAccounts(); | ||
| 259 | }, | ||
| 260 | useQuickAdd: makeUseQuickAdd(presets), | ||
| 261 | }; | ||
| 262 | }; | ||
| 28 | 263 | ||
| 29 | export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); | 264 | export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); |
| @@ -52,6 +52,11 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 51 unmodified lines | |||
| 52 | specUrl: Schema.optional(Schema.String), | 52 | specUrl: Schema.optional(Schema.String), |
| 53 | /** Optional base URL override. */ | 53 | /** Optional base URL override. */ |
| 54 | baseUrl: Schema.optional(Schema.String), | 54 | baseUrl: Schema.optional(Schema.String), |
| 55 | /** The PRODUCT's domain, recorded at add time when the caller knew it (a | ||
| 56 | * registry row names notion.com). The spec's own host is often a code | ||
| 57 | * host (raw.githubusercontent.com) that says nothing about the product, | ||
| 58 | * which sent credential guidance and favicons to the wrong domain. */ | ||
| 59 | displayDomain: Schema.optional(Schema.String), | ||
| 55 | /** Static headers applied to every request (no secret material). */ | 60 | /** Static headers applied to every request (no secret material). */ |
| 56 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 61 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 57 | /** Static query params applied to every request (no secret material). */ | 62 | /** Static query params applied to every request (no secret material). */ |
| @@ -71,3 +71,45 @@ describe("detectedAuthenticationTemplates", () => { · 70 unmodified lines | |||
| 71 | ).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]); | 71 | ).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]); |
| 72 | }); | 72 | }); |
| 73 | }); | 73 | }); |
| 74 | |||
| 75 | describe("query-located api keys", () => { | ||
| 76 | it("a query apiKey preset places the secret in the query string", () => { | ||
| 77 | const templates = detectedAuthenticationTemplates( | ||
| 78 | [ | ||
| 79 | { | ||
| 80 | label: "Legacy-API-key (query)", | ||
| 81 | headers: {}, | ||
| 82 | secretHeaders: [], | ||
| 83 | secretQueryParams: ["apiKey"], | ||
| 84 | }, | ||
| 85 | ], | ||
| 86 | [], | ||
| 87 | "https://example.com", | ||
| 88 | ); | ||
| 89 | expect(templates).toHaveLength(1); | ||
| 90 | const template = templates[0]; | ||
| 91 | expect(template?.kind).toBe("apikey"); | ||
| 92 | expect(template?.kind === "apikey" ? template.placements : []).toEqual([ | ||
| 93 | { carrier: "query", name: "apiKey" }, | ||
| 94 | ]); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("a strategy mixing a header and a query param asks for two inputs", () => { | ||
| 98 | const templates = detectedAuthenticationTemplates( | ||
| 99 | [ | ||
| 100 | { | ||
| 101 | label: "exp-api-key + apiKey (query)", | ||
| 102 | headers: { "exp-api-key": null }, | ||
| 103 | secretHeaders: ["exp-api-key"], | ||
| 104 | secretQueryParams: ["apiKey"], | ||
| 105 | }, | ||
| 106 | ], | ||
| 107 | [], | ||
| 108 | "https://example.com", | ||
| 109 | ); | ||
| 110 | const template = templates[0]; | ||
| 111 | const placements = template?.kind === "apikey" ? template.placements : []; | ||
| 112 | expect(placements.map((placement) => placement.carrier)).toEqual(["header", "query"]); | ||
| 113 | expect(new Set(placements.map((placement) => placement.variable)).size).toBe(2); | ||
| 114 | }); | ||
| 115 | }); | ||
| @@ -104,20 +104,33 @@ const apiKeyTemplateFromHeaderPreset = ( · 103 unmodified lines | |||
| 104 | preset: HeaderPreset, | 104 | preset: HeaderPreset, |
| 105 | slug: AuthTemplateSlug, | 105 | slug: AuthTemplateSlug, |
| 106 | ): APIKeyAuthentication => { | 106 | ): APIKeyAuthentication => { |
| 107 | const variables = variablesForHeaders(preset.secretHeaders); | 107 | const secretQueryParams = preset.secretQueryParams ?? []; |
| 108 | // One variable namespace across carriers, so a strategy sending two secrets | ||
| 109 | // (a header and a query param) asks for two distinct inputs. | ||
| 110 | const variables = variablesForHeaders([...preset.secretHeaders, ...secretQueryParams]); | ||
| 108 | return { | 111 | return { |
| 109 | slug, | 112 | slug, |
| 110 | kind: "apikey", | 113 | kind: "apikey", |
| 111 | placements: preset.secretHeaders.map((headerName) => { | 114 | placements: [ |
| 112 | const prefix = headerPrefix(preset, headerName); | 115 | ...preset.secretHeaders.map((headerName) => { |
| 113 | const variable = variables.get(headerName); | 116 | const prefix = headerPrefix(preset, headerName); |
| 114 | return { | 117 | const variable = variables.get(headerName); |
| 115 | carrier: "header" as const, | 118 | return { |
| 116 | name: headerName, | 119 | carrier: "header" as const, |
| 117 | ...(prefix ? { prefix } : {}), | 120 | name: headerName, |
| 118 | ...(variable ? { variable } : {}), | 121 | ...(prefix ? { prefix } : {}), |
| 119 | }; | 122 | ...(variable ? { variable } : {}), |
| 120 | }), | 123 | }; |
| 124 | }), | ||
| 125 | ...secretQueryParams.map((paramName) => { | ||
| 126 | const variable = variables.get(paramName); | ||
| 127 | return { | ||
| 128 | carrier: "query" as const, | ||
| 129 | name: paramName, | ||
| 130 | ...(variable ? { variable } : {}), | ||
| 131 | }; | ||
| 132 | }), | ||
| 133 | ], | ||
| 121 | }; | 134 | }; |
| 122 | }; | 135 | }; |
| 123 | 136 | ||
| @@ -98,6 +98,10 @@ export interface OpenApiSpecConfig { · 97 unmodified lines | |||
| 98 | * then the title). */ | 98 | * then the title). */ |
| 99 | readonly description?: string; | 99 | readonly description?: string; |
| 100 | readonly baseUrl?: string; | 100 | readonly baseUrl?: string; |
| 101 | /** The product's domain, when the caller knows it (a registry row names | ||
| 102 | * notion.com); display identity for guidance/favicons when the spec's | ||
| 103 | * own host is a code host. */ | ||
| 104 | readonly displayDomain?: string; | ||
| 101 | /** Static headers applied to every request (no secret material). */ | 105 | /** Static headers applied to every request (no secret material). */ |
| 102 | readonly headers?: Record<string, string>; | 106 | readonly headers?: Record<string, string>; |
| 103 | /** Static query params applied to every request. */ | 107 | /** Static query params applied to every request. */ |
| @@ -307,6 +311,7 @@ const AddIntegrationInputSchema = Schema.Struct({ · 203 unmodified lines | |||
| 307 | name: Schema.optional(Schema.String), | 311 | name: Schema.optional(Schema.String), |
| 308 | description: Schema.optional(Schema.String), | 312 | description: Schema.optional(Schema.String), |
| 309 | baseUrl: Schema.optional(Schema.String), | 313 | baseUrl: Schema.optional(Schema.String), |
| 314 | displayDomain: Schema.optional(Schema.String), | ||
| 310 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 315 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 311 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 316 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 312 | specFormat: Schema.optional(Schema.String), | 317 | specFormat: Schema.optional(Schema.String), |
| @@ -603,7 +608,9 @@ export const describeOpenApiIntegrationDisplay = ( · 290 unmodified lines | |||
| 603 | ): { readonly url?: string; readonly family?: string } => { | 608 | ): { readonly url?: string; readonly family?: string } => { |
| 604 | const config = decodeOpenApiIntegrationConfig(record.config); | 609 | const config = decodeOpenApiIntegrationConfig(record.config); |
| 605 | return { | 610 | return { |
| 606 | url: config?.baseUrl ?? config?.specUrl, | 611 | url: |
| 612 | config?.baseUrl ?? | ||
| 613 | (config?.displayDomain ? `https://${config.displayDomain}` : config?.specUrl), | ||
| 607 | ...(config?.family ? { family: config.family } : {}), | 614 | ...(config?.family ? { family: config.family } : {}), |
| 608 | }; | 615 | }; |
| 609 | }; | 616 | }; |
| @@ -661,6 +668,7 @@ export const openApiPlugin = definePlugin< · 51 unmodified lines | |||
| 661 | const adapter = yield* resolveSpecFormatAdapter( | 668 | const adapter = yield* resolveSpecFormatAdapter( |
| 662 | options?.specFormats ?? [], | 669 | options?.specFormats ?? [], |
| 663 | config.specFormat, | 670 | config.specFormat, |
| 671 | config.spec.kind === "url" ? config.spec.url : undefined, | ||
| 664 | ); | 672 | ); |
| 665 | if (adapter) { | 673 | if (adapter) { |
| 666 | if (config.spec.kind !== "url") { | 674 | if (config.spec.kind !== "url") { |
| @@ -782,6 +790,7 @@ export const openApiPlugin = definePlugin< · 115 unmodified lines | |||
| 782 | const adapter = yield* resolveSpecFormatAdapter( | 790 | const adapter = yield* resolveSpecFormatAdapter( |
| 783 | options?.specFormats ?? [], | 791 | options?.specFormats ?? [], |
| 784 | config.specFormat, | 792 | config.specFormat, |
| 793 | config.spec.kind === "url" ? config.spec.url : undefined, | ||
| 785 | ); | 794 | ); |
| 786 | const derivedIdentity = | 795 | const derivedIdentity = |
| 787 | adapter?.deriveIdentity && resolved.document | 796 | adapter?.deriveIdentity && resolved.document |
| @@ -865,6 +874,7 @@ export const openApiPlugin = definePlugin< · 77 unmodified lines | |||
| 865 | // resolved per call from the operation's `servers` (extracted from | 874 | // resolved per call from the operation's `servers` (extracted from |
| 866 | // the spec), so we never bake a derived base URL into the config. | 875 | // the spec), so we never bake a derived base URL into the config. |
| 867 | ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), | 876 | ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), |
| 877 | ...(config.displayDomain ? { displayDomain: config.displayDomain } : {}), | ||
| 868 | ...(config.headers ? { headers: config.headers } : {}), | 878 | ...(config.headers ? { headers: config.headers } : {}), |
| 869 | ...(config.queryParams ? { queryParams: config.queryParams } : {}), | 879 | ...(config.queryParams ? { queryParams: config.queryParams } : {}), |
| 870 | ...(config.specFormat ? { specFormat: config.specFormat } : {}), | 880 | ...(config.specFormat ? { specFormat: config.specFormat } : {}), |
| @@ -145,7 +145,9 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 144 unmodified lines | |||
| 145 | id: "axiom", | 145 | id: "axiom", |
| 146 | name: "Axiom", | 146 | name: "Axiom", |
| 147 | summary: "Log ingestion, querying, datasets, and monitors.", | 147 | summary: "Log ingestion, querying, datasets, and monitors.", |
| 148 | url: "https://axiom.co/docs/restapi/versions/v2.json", | 148 | // axiom.co/docs/restapi/versions/v2.json now 404s; the docs repo is where |
| 149 | // Axiom actually publishes the spec. | ||
| 150 | url: "https://raw.githubusercontent.com/axiomhq/docs/main/content/docs/%28api-reference%29/restapi/versions/v2.json", | ||
| 149 | icon: "https://integrations.sh/logo/axiom.co", | 151 | icon: "https://integrations.sh/logo/axiom.co", |
| 150 | }, | 152 | }, |
| 151 | { | 153 | { |
| @@ -169,13 +171,6 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 17 unmodified lines | |||
| 169 | url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml", | 171 | url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml", |
| 170 | icon: "https://integrations.sh/logo/digitalocean.com", | 172 | icon: "https://integrations.sh/logo/digitalocean.com", |
| 171 | }, | 173 | }, |
| 172 | { | ||
| 173 | id: "petstore", | ||
| 174 | name: "Petstore", | ||
| 175 | summary: "Classic OpenAPI demo, no auth required.", | ||
| 176 | url: "https://petstore3.swagger.io/api/v3/openapi.json", | ||
| 177 | icon: "https://integrations.sh/logo/petstore3.swagger.io", | ||
| 178 | }, | ||
| 179 | { | 174 | { |
| 180 | id: "val-town", | 175 | id: "val-town", |
| 181 | name: "Val Town", | 176 | name: "Val Town", |
| @@ -122,6 +122,9 @@ export const HeaderPreset = Schema.Struct({ · 121 unmodified lines | |||
| 122 | headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)), | 122 | headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)), |
| 123 | /** Which headers should be stored as secrets */ | 123 | /** Which headers should be stored as secrets */ |
| 124 | secretHeaders: Schema.Array(Schema.String), | 124 | secretHeaders: Schema.Array(Schema.String), |
| 125 | /** Query parameters the strategy sends the secret in (apiKey in=query, | ||
| 126 | * e.g. Viator's legacy `?apiKey=`). Absent on older stored previews. */ | ||
| 127 | secretQueryParams: Schema.optional(Schema.Array(Schema.String)), | ||
| 125 | }); | 128 | }); |
| 126 | export type HeaderPreset = typeof HeaderPreset.Type; | 129 | export type HeaderPreset = typeof HeaderPreset.Type; |
| 127 | 130 | ||
| @@ -347,6 +350,7 @@ const buildHeaderPresets = ( · 219 unmodified lines | |||
| 347 | 350 | ||
| 348 | const headers: Record<string, string | null> = {}; | 351 | const headers: Record<string, string | null> = {}; |
| 349 | const secretHeaders: string[] = []; | 352 | const secretHeaders: string[] = []; |
| 353 | const secretQueryParams: string[] = []; | ||
| 350 | const labelParts: string[] = []; | 354 | const labelParts: string[] = []; |
| 351 | 355 | ||
| 352 | for (const scheme of resolved) { | 356 | for (const scheme of resolved) { |
| @@ -363,8 +367,16 @@ const buildHeaderPresets = ( · 10 unmodified lines | |||
| 363 | headers[headerName] = null; | 367 | headers[headerName] = null; |
| 364 | secretHeaders.push(headerName); | 368 | secretHeaders.push(headerName); |
| 365 | labelParts.push(scheme.name); | 369 | labelParts.push(scheme.name); |
| 370 | } else if (scheme.type === "apiKey" && Option.getOrElse(scheme.in, () => "") === "query") { | ||
| 371 | secretQueryParams.push(Option.getOrElse(scheme.headerName, () => scheme.name)); | ||
| 372 | labelParts.push(`${scheme.name} (query)`); | ||
| 366 | } else if (scheme.type === "apiKey") { | 373 | } else if (scheme.type === "apiKey") { |
| 367 | labelParts.push(`${scheme.name} (${Option.getOrElse(scheme.in, () => "unknown")})`); | 374 | // Cookie (and unknown) locations are not renderable as a stored |
| 375 | // method — auth placements carry header|query — and a cookie scheme | ||
| 376 | // is usually the vendor console's own session, not a mintable | ||
| 377 | // credential. Contributing a label here used to produce a method | ||
| 378 | // with zero placements: an empty, unfillable card in the add flow. | ||
| 379 | continue; | ||
| 368 | } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") { | 380 | } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") { |
| 369 | return []; | 381 | return []; |
| 370 | } else { | 382 | } else { |
| @@ -372,21 +384,16 @@ const buildHeaderPresets = ( · 1 unmodified line | |||
| 372 | } | 384 | } |
| 373 | } | 385 | } |
| 374 | 386 | ||
| 375 | if (Object.keys(headers).length === 0 && resolved.length > 0) { | 387 | // A strategy in which nothing is renderable (cookie-only, or an exotic |
| 376 | return [ | 388 | // scheme type) yields no preset rather than an empty one. |
| 377 | HeaderPreset.make({ | 389 | if (secretHeaders.length === 0 && secretQueryParams.length === 0) return []; |
| 378 | label: labelParts.join(" + "), | ||
| 379 | headers: {}, | ||
| 380 | secretHeaders: [], | ||
| 381 | }), | ||
| 382 | ]; | ||
| 383 | } | ||
| 384 | 390 | ||
| 385 | return [ | 391 | return [ |
| 386 | HeaderPreset.make({ | 392 | HeaderPreset.make({ |
| 387 | label: labelParts.join(" + "), | 393 | label: labelParts.join(" + "), |
| 388 | headers, | 394 | headers, |
| 389 | secretHeaders, | 395 | secretHeaders, |
| 396 | ...(secretQueryParams.length > 0 ? { secretQueryParams } : {}), | ||
| 390 | }), | 397 | }), |
| 391 | ]; | 398 | ]; |
| 392 | }); | 399 | }); |
| @@ -41,13 +41,24 @@ export interface SpecFormatAdapter { · 40 unmodified lines | |||
| 41 | readonly id: string; | 41 | readonly id: string; |
| 42 | readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>; | 42 | readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>; |
| 43 | readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null; | 43 | readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null; |
| 44 | /** Recognizes URLs this adapter can convert. Lets a hand-pasted URL take | ||
| 45 | * the same path a preset would: without this, a Google Discovery URL only | ||
| 46 | * worked through a preset carrying `specFormat`, and pasting the identical | ||
| 47 | * URL failed as "not OpenAPI". */ | ||
| 48 | readonly detectsUrl?: (url: string) => boolean; | ||
| 44 | } | 49 | } |
| 45 | 50 | ||
| 46 | export const resolveSpecFormatAdapter = ( | 51 | export const resolveSpecFormatAdapter = ( |
| 47 | adapters: readonly SpecFormatAdapter[], | 52 | adapters: readonly SpecFormatAdapter[], |
| 48 | id: string | undefined, | 53 | id: string | undefined, |
| 54 | url?: string, | ||
| 49 | ): Effect.Effect<SpecFormatAdapter | null, OpenApiParseError> => { | 55 | ): Effect.Effect<SpecFormatAdapter | null, OpenApiParseError> => { |
| 50 | if (!id) return Effect.succeed(null); | 56 | if (!id) { |
| 57 | if (!url) return Effect.succeed(null); | ||
| 58 | return Effect.succeed( | ||
| 59 | adapters.find((candidate) => candidate.detectsUrl?.(url) === true) ?? null, | ||
| 60 | ); | ||
| 61 | } | ||
| 51 | const adapter = adapters.find((candidate) => candidate.id === id); | 62 | const adapter = adapters.find((candidate) => candidate.id === id); |
| 52 | if (adapter) return Effect.succeed(adapter); | 63 | if (adapter) return Effect.succeed(adapter); |
| 53 | return Effect.fail(new OpenApiParseError({ message: `Unknown OpenAPI spec format: ${id}` })); | 64 | return Effect.fail(new OpenApiParseError({ message: `Unknown OpenAPI spec format: ${id}` })); |
| @@ -1,5 +1,21 @@ | |||
| 1 | # @executor-js/plugin-provider-service-split | 1 | # @executor-js/plugin-provider-service-split |
| 2 | 2 | ||
| 3 | ## 0.0.20 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/plugin-openapi@1.6.6 | ||
| 9 | - @executor-js/sdk@1.6.6 | ||
| 10 | |||
| 11 | ## 0.0.19 | ||
| 12 | |||
| 13 | ### Patch Changes | ||
| 14 | |||
| 15 | - Updated dependencies []: | ||
| 16 | - @executor-js/sdk@1.6.5 | ||
| 17 | - @executor-js/plugin-openapi@1.6.5 | ||
| 18 | |||
| 3 | ## 0.0.18 | 19 | ## 0.0.18 |
| 4 | 20 | ||
| 5 | ### Patch Changes | 21 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-provider-service-split", | 2 | "name": "@executor-js/plugin-provider-service-split", |
| 3 | "version": "0.0.18", | 3 | "version": "0.0.20", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "exports": { | 6 | "exports": { |
| @@ -1,5 +1,23 @@ | |||
| 1 | # @executor-js/plugin-toolkits | 1 | # @executor-js/plugin-toolkits |
| 2 | 2 | ||
| 3 | ## 1.5.41 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: | ||
| 8 | - @executor-js/react@1.4.69 | ||
| 9 | - @executor-js/api@1.4.69 | ||
| 10 | - @executor-js/sdk@1.6.6 | ||
| 11 | |||
| 12 | ## 1.5.40 | ||
| 13 | |||
| 14 | ### Patch Changes | ||
| 15 | |||
| 16 | - Updated dependencies []: | ||
| 17 | - @executor-js/sdk@1.6.5 | ||
| 18 | - @executor-js/api@1.4.68 | ||
| 19 | - @executor-js/react@1.4.68 | ||
| 20 | |||
| 3 | ## 1.5.39 | 21 | ## 1.5.39 |
| 4 | 22 | ||
| 5 | ### Patch Changes | 23 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-toolkits", | 2 | "name": "@executor-js/plugin-toolkits", |
| 3 | "version": "1.5.39", | 3 | "version": "1.5.41", |
| 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/toolkits", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/toolkits", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| @@ -1,5 +1,27 @@ | |||
| 1 | # @executor-js/react | 1 | # @executor-js/react |
| 2 | 2 | ||
| 3 | ## 1.4.69 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - [#1865](https://github.com/UsefulSoftwareCo/executor/pull/1865) [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Desktop OAuth connects finish the moment the provider redirects** | ||
| 8 | |||
| 9 | When the desktop app runs an OAuth flow in the system browser, the app learned about completion by polling the local server once a second. The completed result sat in memory while the user watched the "Connecting…" spinner for up to a second more — about half a second wasted on average, on every connect. | ||
| 10 | |||
| 11 | The await endpoint now long-polls: the server holds the request open (up to 25 seconds per hold) and answers the instant the flow completes. The client polls one request at a time and reconnects after each answer, so requests never stack. Mixed versions stay compatible in both directions: an old client still gets its answer within one poll of a new server, and a new client against an old server behaves exactly as before. | ||
| 12 | |||
| 13 | - Updated dependencies []: | ||
| 14 | - @executor-js/api@1.4.69 | ||
| 15 | - @executor-js/sdk@1.6.6 | ||
| 16 | |||
| 17 | ## 1.4.68 | ||
| 18 | |||
| 19 | ### Patch Changes | ||
| 20 | |||
| 21 | - Updated dependencies []: | ||
| 22 | - @executor-js/sdk@1.6.5 | ||
| 23 | - @executor-js/api@1.4.68 | ||
| 24 | |||
| 3 | ## 1.4.67 | 25 | ## 1.4.67 |
| 4 | 26 | ||
| 5 | ### Patch Changes | 27 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/react", | 2 | "name": "@executor-js/react", |
| 3 | "version": "1.4.67", | 3 | "version": "1.4.69", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "exports": { | 6 | "exports": { |
| @@ -13,7 +13,7 @@ afterEach(() => { · 12 unmodified lines | |||
| 13 | 13 | ||
| 14 | describe("analytics seam", () => { | 14 | describe("analytics seam", () => { |
| 15 | it("is a no-op when no client is mounted", () => { | 15 | it("is a no-op when no client is mounted", () => { |
| 16 | expect(() => trackEvent("integration_connect_dialog_opened")).not.toThrow(); | 16 | expect(() => trackEvent("integration_browse_opened", { via: "header" })).not.toThrow(); |
| 17 | }); | 17 | }); |
| 18 | 18 | ||
| 19 | it("forwards name and properties to the mounted client", () => { | 19 | it("forwards name and properties to the mounted client", () => { |
| @@ -31,12 +31,22 @@ type Owner = "org" | "user"; · 30 unmodified lines | |||
| 31 | 31 | ||
| 32 | export interface AnalyticsEvents { | 32 | export interface AnalyticsEvents { |
| 33 | // ── Integrations ───────────────────────────────────────────────────────── | 33 | // ── Integrations ───────────────────────────────────────────────────────── |
| 34 | integration_connect_dialog_opened: {}; | 34 | /** The full-page picker was opened. Replaces the connect dialog, whose |
| 35 | * `integration_connect_dialog_opened` this supersedes — keep both readable | ||
| 36 | * in dashboards spanning the change. */ | ||
| 37 | integration_browse_opened: { via: "header" | "empty-state" | "sidebar" }; | ||
| 35 | integration_detect_submitted: { | 38 | integration_detect_submitted: { |
| 36 | success: boolean; | 39 | success: boolean; |
| 37 | detected_kind?: string; | 40 | detected_kind?: string; |
| 38 | confidence?: string; | 41 | confidence?: string; |
| 39 | }; | 42 | }; |
| 43 | /** Thumbs on the AI-generated credential guidance panel — the accuracy | ||
| 44 | * signal for the registry's machine-written setup text. */ | ||
| 45 | credential_guidance_rated: { | ||
| 46 | domain: string; | ||
| 47 | credential_label: string; | ||
| 48 | vote: "up" | "down"; | ||
| 49 | }; | ||
| 40 | integration_add_started: { | 50 | integration_add_started: { |
| 41 | plugin_key: string; | 51 | plugin_key: string; |
| 42 | via: "detect" | "manual" | "preset" | "command_palette" | "catalog"; | 52 | via: "detect" | "manual" | "preset" | "command_palette" | "catalog"; |
| @@ -1,6 +1,12 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { openOAuthPopup, reserveOAuthPopup } from "./oauth-popup"; | 3 | import { |
| 4 | OAUTH_POPUP_MESSAGE_TYPE, | ||
| 5 | openOAuthPopup, | ||
| 6 | openOAuthSystemBrowser, | ||
| 7 | reserveOAuthPopup, | ||
| 8 | type OAuthPopupResult, | ||
| 9 | } from "./oauth-popup"; | ||
| 4 | 10 | ||
| 5 | type OAuthPopupTestWindow = { | 11 | type OAuthPopupTestWindow = { |
| 6 | readonly screenX: number; | 12 | readonly screenX: number; |
| @@ -228,3 +234,125 @@ describe("openOAuthPopup", () => { · 221 unmodified lines | |||
| 228 | expect(closedCalled).toBe(false); | 234 | expect(closedCalled).toBe(false); |
| 229 | }); | 235 | }); |
| 230 | }); | 236 | }); |
| 237 | |||
| 238 | describe("openOAuthSystemBrowser", () => { | ||
| 239 | const sessionResult = (sessionId: string): OAuthPopupResult<unknown> => ({ | ||
| 240 | type: OAUTH_POPUP_MESSAGE_TYPE, | ||
| 241 | ok: false, | ||
| 242 | sessionId, | ||
| 243 | error: "access denied", | ||
| 244 | }); | ||
| 245 | |||
| 246 | const waitFor = async (predicate: () => boolean): Promise<void> => { | ||
| 247 | const deadline = Date.now() + 2000; | ||
| 248 | while (!predicate() && Date.now() < deadline) { | ||
| 249 | await new Promise<void>((resolve) => setTimeout(resolve, 5)); | ||
| 250 | } | ||
| 251 | expect(predicate()).toBe(true); | ||
| 252 | }; | ||
| 253 | |||
| 254 | const withFakeFetch = async (fake: typeof fetch, run: () => Promise<void>): Promise<void> => { | ||
| 255 | const previousFetch = globalThis.fetch; | ||
| 256 | globalThis.fetch = fake; | ||
| 257 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always restore the global fetch | ||
| 258 | try { | ||
| 259 | await run(); | ||
| 260 | } finally { | ||
| 261 | globalThis.fetch = previousFetch; | ||
| 262 | } | ||
| 263 | }; | ||
| 264 | |||
| 265 | it("never stacks overlapping await requests while the server holds one open", async () => { | ||
| 266 | let inFlight = 0; | ||
| 267 | let maxInFlight = 0; | ||
| 268 | let calls = 0; | ||
| 269 | let received: unknown = null; | ||
| 270 | |||
| 271 | // Simulates a long-polling server: the request is held ~40ms, then | ||
| 272 | // answers with the completed result. | ||
| 273 | const fake: typeof fetch = async () => { | ||
| 274 | calls += 1; | ||
| 275 | inFlight += 1; | ||
| 276 | maxInFlight = Math.max(maxInFlight, inFlight); | ||
| 277 | await new Promise<void>((resolve) => setTimeout(resolve, 40)); | ||
| 278 | inFlight -= 1; | ||
| 279 | return new Response(JSON.stringify(sessionResult("s-hold"))); | ||
| 280 | }; | ||
| 281 | |||
| 282 | await withFakeFetch(fake, async () => { | ||
| 283 | const teardown = openOAuthSystemBrowser({ | ||
| 284 | url: "https://auth.example/authorize", | ||
| 285 | sessionId: "s-hold", | ||
| 286 | openExternal: async () => {}, | ||
| 287 | onResult: (result) => { | ||
| 288 | received = result; | ||
| 289 | }, | ||
| 290 | // Far shorter than the held request: the old setInterval loop would | ||
| 291 | // stack many concurrent requests here. | ||
| 292 | pollMs: 1, | ||
| 293 | }); | ||
| 294 | await waitFor(() => received !== null); | ||
| 295 | teardown(); | ||
| 296 | }); | ||
| 297 | |||
| 298 | expect(calls).toBe(1); | ||
| 299 | expect(maxInFlight).toBe(1); | ||
| 300 | expect(received).toMatchObject({ sessionId: "s-hold" }); | ||
| 301 | }); | ||
| 302 | |||
| 303 | it("reconnects after a pending (null) answer and delivers the next result", async () => { | ||
| 304 | let calls = 0; | ||
| 305 | let received: unknown = null; | ||
| 306 | |||
| 307 | // First request answers "still pending" (an old server, or a long-poll | ||
| 308 | // deadline); the second delivers the result. | ||
| 309 | const fake: typeof fetch = async () => { | ||
| 310 | calls += 1; | ||
| 311 | const body = calls === 1 ? null : sessionResult("s-retry"); | ||
| 312 | return new Response(JSON.stringify(body)); | ||
| 313 | }; | ||
| 314 | |||
| 315 | await withFakeFetch(fake, async () => { | ||
| 316 | const teardown = openOAuthSystemBrowser({ | ||
| 317 | url: "https://auth.example/authorize", | ||
| 318 | sessionId: "s-retry", | ||
| 319 | openExternal: async () => {}, | ||
| 320 | onResult: (result) => { | ||
| 321 | received = result; | ||
| 322 | }, | ||
| 323 | pollMs: 1, | ||
| 324 | }); | ||
| 325 | await waitFor(() => received !== null); | ||
| 326 | teardown(); | ||
| 327 | }); | ||
| 328 | |||
| 329 | expect(calls).toBe(2); | ||
| 330 | expect(received).toMatchObject({ sessionId: "s-retry" }); | ||
| 331 | }); | ||
| 332 | |||
| 333 | it("aborts the in-flight await request on teardown", async () => { | ||
| 334 | const captured: { signal: AbortSignal | null } = { signal: null }; | ||
| 335 | |||
| 336 | // Held open indefinitely; on abort it answers "still pending" the way a | ||
| 337 | // real aborted fetch stops mattering — the loop is already settled. | ||
| 338 | const fake: typeof fetch = (_input, init) => | ||
| 339 | new Promise<Response>((resolve) => { | ||
| 340 | captured.signal = init?.signal ?? null; | ||
| 341 | init?.signal?.addEventListener("abort", () => resolve(new Response("null"))); | ||
| 342 | }); | ||
| 343 | |||
| 344 | await withFakeFetch(fake, async () => { | ||
| 345 | const teardown = openOAuthSystemBrowser({ | ||
| 346 | url: "https://auth.example/authorize", | ||
| 347 | sessionId: "s-abort", | ||
| 348 | openExternal: async () => {}, | ||
| 349 | onResult: () => {}, | ||
| 350 | }); | ||
| 351 | await waitFor(() => captured.signal !== null); | ||
| 352 | teardown(); | ||
| 353 | await waitFor(() => captured.signal?.aborted === true); | ||
| 354 | }); | ||
| 355 | |||
| 356 | expect(captured.signal?.aborted).toBe(true); | ||
| 357 | }); | ||
| 358 | }); | ||
| @@ -238,7 +238,13 @@ export type OpenOAuthSystemBrowserInput<TAuth> = { · 237 unmodified lines | |||
| 238 | readonly onResult: (data: OAuthPopupResult<TAuth>) => void; | 238 | readonly onResult: (data: OAuthPopupResult<TAuth>) => void; |
| 239 | /** Called once if the external open itself fails (URL rejected, IPC error). */ | 239 | /** Called once if the external open itself fails (URL rejected, IPC error). */ |
| 240 | readonly onOpenFailed?: (cause: unknown) => void; | 240 | readonly onOpenFailed?: (cause: unknown) => void; |
| 241 | /** Poll cadence. Default 1000ms. */ | 241 | /** |
| 242 | * Delay between poll attempts, applied only after the previous request | ||
| 243 | * settles (requests never overlap). New local servers long-poll the await | ||
| 244 | * route and answer the moment the flow completes, so this is a reconnect | ||
| 245 | * cadence; old servers answer immediately, making this a plain poll | ||
| 246 | * interval as before. Default 1000ms. | ||
| 247 | */ | ||
| 242 | readonly pollMs?: number; | 248 | readonly pollMs?: number; |
| 243 | /** Stop polling after this many ms with no result. Default 10 minutes. */ | 249 | /** Stop polling after this many ms with no result. Default 10 minutes. */ |
| 244 | readonly timeoutMs?: number; | 250 | readonly timeoutMs?: number; |
| @@ -252,14 +258,14 @@ export const openOAuthSystemBrowser = <TAuth>( · 7 unmodified lines | |||
| 252 | input: OpenOAuthSystemBrowserInput<TAuth>, | 258 | input: OpenOAuthSystemBrowserInput<TAuth>, |
| 253 | ): (() => void) => { | 259 | ): (() => void) => { |
| 254 | let settled = false; | 260 | let settled = false; |
| 255 | let pollHandle: ReturnType<typeof setInterval> | null = null; | 261 | let pollTimer: ReturnType<typeof setTimeout> | null = null; |
| 256 | let timeoutHandle: ReturnType<typeof setTimeout> | null = null; | 262 | let timeoutHandle: ReturnType<typeof setTimeout> | null = null; |
| 257 | const controller = new AbortController(); | 263 | const controller = new AbortController(); |
| 258 | 264 | ||
| 259 | const settle = () => { | 265 | const settle = () => { |
| 260 | if (settled) return; | 266 | if (settled) return; |
| 261 | settled = true; | 267 | settled = true; |
| 262 | if (pollHandle !== null) clearInterval(pollHandle); | 268 | if (pollTimer !== null) clearTimeout(pollTimer); |
| 263 | if (timeoutHandle !== null) clearTimeout(timeoutHandle); | 269 | if (timeoutHandle !== null) clearTimeout(timeoutHandle); |
| 264 | controller.abort(); | 270 | controller.abort(); |
| 265 | }; | 271 | }; |
| @@ -299,8 +305,23 @@ export const openOAuthSystemBrowser = <TAuth>( · 33 unmodified lines | |||
| 299 | } | 305 | } |
| 300 | })(); | 306 | })(); |
| 301 | 307 | ||
| 302 | pollHandle = setInterval(() => void poll(), input.pollMs ?? OAUTH_AWAIT_DEFAULT_POLL_MS); | 308 | // Sequential poll loop: one request at a time, reconnecting `pollMs` after |
| 303 | void poll(); | 309 | // the previous one settles. A long-polling server may hold each request for |
| 310 | // many seconds, so a fixed interval would stack overlapping requests. | ||
| 311 | const scheduleReconnect = () => { | ||
| 312 | if (settled) return; | ||
| 313 | pollTimer = setTimeout(() => { | ||
| 314 | pollTimer = null; | ||
| 315 | void runPoll(); | ||
| 316 | }, input.pollMs ?? OAUTH_AWAIT_DEFAULT_POLL_MS); | ||
| 317 | }; | ||
| 318 | const runPoll = async () => { | ||
| 319 | if (settled) return; | ||
| 320 | await poll(); | ||
| 321 | scheduleReconnect(); | ||
| 322 | }; | ||
| 323 | |||
| 324 | void runPoll(); | ||
| 304 | timeoutHandle = setTimeout(() => { | 325 | timeoutHandle = setTimeout(() => { |
| 305 | if (settled) return; | 326 | if (settled) return; |
| 306 | settle(); | 327 | settle(); |
| @@ -27,6 +27,7 @@ import { · 26 unmodified lines | |||
| 27 | checkConnectionHealth, | 27 | checkConnectionHealth, |
| 28 | connectionsAllAtom, | 28 | connectionsAllAtom, |
| 29 | createOAuthClientOptimistic, | 29 | createOAuthClientOptimistic, |
| 30 | integrationAtom, | ||
| 30 | integrationHealthCheckAtom, | 31 | integrationHealthCheckAtom, |
| 31 | integrationHealthCheckCandidatesAtom, | 32 | integrationHealthCheckCandidatesAtom, |
| 32 | oauthClientsOptimisticAtom, | 33 | oauthClientsOptimisticAtom, |
| @@ -85,6 +86,7 @@ import { · 52 unmodified lines | |||
| 85 | } from "./oauth-client-form"; | 86 | } from "./oauth-client-form"; |
| 86 | import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog"; | 87 | import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog"; |
| 87 | import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal"; | 88 | import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal"; |
| 89 | import { CredentialGuidancePanel } from "./credential-guidance"; | ||
| 88 | import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; | 90 | import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; |
| 89 | import { connectionIdentifier } from "../lib/connection-name"; | 91 | import { connectionIdentifier } from "../lib/connection-name"; |
| 90 | import { Badge } from "./badge"; | 92 | import { Badge } from "./badge"; |
| @@ -625,6 +627,14 @@ export const connectionExistsMessage = (label: string): string => · 534 unmodified lines | |||
| 625 | * explicit choice. Personal: a connection is most often a personal credential. */ | 627 | * explicit choice. Personal: a connection is most often a personal credential. */ |
| 626 | export const DEFAULT_CONNECTION_OWNER: Owner = "user"; | 628 | export const DEFAULT_CONNECTION_OWNER: Owner = "user"; |
| 627 | 629 | ||
| 630 | /** The method the modal opens on. OAuth needs a registered app (or a DCR | ||
| 631 | * round-trip) before "Connect" does anything; a key is one paste. When an | ||
| 632 | * integration declares both, starting on OAuth greets most users with | ||
| 633 | * "Register app" — a dead end — while the working method sits one tab over. | ||
| 634 | * Prefer the first non-OAuth method; OAuth stays one click away. */ | ||
| 635 | export const preferredMethodId = (methods: readonly AuthMethod[]): string => | ||
| 636 | (methods.find((method) => method.kind !== "oauth") ?? methods[0])?.id ?? ""; | ||
| 637 | |||
| 628 | const authMethodKey = (method: AuthMethod): string => | 638 | const authMethodKey = (method: AuthMethod): string => |
| 629 | method.source === "custom" ? `custom:${String(method.template)}` : `declared:${method.id}`; | 639 | method.source === "custom" ? `custom:${String(method.template)}` : `declared:${method.id}`; |
| 630 | 640 | ||
| @@ -1423,7 +1433,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 792 unmodified lines | |||
| 1423 | ); | 1433 | ); |
| 1424 | const [addingMethod, setAddingMethod] = useState(false); | 1434 | const [addingMethod, setAddingMethod] = useState(false); |
| 1425 | 1435 | ||
| 1426 | const [methodId, setMethodId] = useState<string>(methods[0]?.id ?? ""); | 1436 | const [methodId, setMethodId] = useState<string>(preferredMethodId(methods)); |
| 1427 | // One value per distinct credential input (`variable → pasted value`). A | 1437 | // One value per distinct credential input (`variable → pasted value`). A |
| 1428 | // single-secret method has just `{ token }`; a method with two distinct inputs | 1438 | // single-secret method has just `{ token }`; a method with two distinct inputs |
| 1429 | // (e.g. Datadog's two keys) collects one value per variable. | 1439 | // (e.g. Datadog's two keys) collects one value per variable. |
| @@ -1501,6 +1511,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 71 unmodified lines | |||
| 1501 | // The integration's declared health check + its candidate operations. When a | 1511 | // The integration's declared health check + its candidate operations. When a |
| 1502 | // check is configured we probe against it; when not, the user picks one of the | 1512 | // check is configured we probe against it; when not, the user picks one of the |
| 1503 | // candidates inline to test the key (and we save it). | 1513 | // candidates inline to test the key (and we save it). |
| 1514 | // The integration's display URL is how the registry's credential guidance is | ||
| 1515 | // located — it names the provider this key belongs to. | ||
| 1516 | const integrationRecord = useAtomValue(integrationAtom(integration)); | ||
| 1517 | const integrationDisplayUrl = AsyncResult.isSuccess(integrationRecord) | ||
| 1518 | ? integrationRecord.value?.displayUrl | ||
| 1519 | : undefined; | ||
| 1504 | const healthCheckResult = useAtomValue(integrationHealthCheckAtom(integration)); | 1520 | const healthCheckResult = useAtomValue(integrationHealthCheckAtom(integration)); |
| 1505 | const configuredHealthCheck = AsyncResult.isSuccess(healthCheckResult) | 1521 | const configuredHealthCheck = AsyncResult.isSuccess(healthCheckResult) |
| 1506 | ? healthCheckResult.value | 1522 | ? healthCheckResult.value |
| @@ -1605,7 +1621,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 98 unmodified lines | |||
| 1605 | m.id === initialState.template || String(m.template) === initialState.template, | 1621 | m.id === initialState.template || String(m.template) === initialState.template, |
| 1606 | ) | 1622 | ) |
| 1607 | : undefined; | 1623 | : undefined; |
| 1608 | setMethodId(initialMethod?.id ?? allMethods[0]!.id); | 1624 | setMethodId(initialMethod?.id ?? preferredMethodId(allMethods)); |
| 1609 | }, [allMethods, initialState?.template, methodId]); | 1625 | }, [allMethods, initialState?.template, methodId]); |
| 1610 | 1626 | ||
| 1611 | // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link. | 1627 | // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link. |
| @@ -2894,6 +2910,15 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1282 unmodified lines | |||
| 2894 | <div className="space-y-2"> | 2910 | <div className="space-y-2"> |
| 2895 | <StepHeader index={1} label={authStepLabel} /> | 2911 | <StepHeader index={1} label={authStepLabel} /> |
| 2896 | 2912 | ||
| 2913 | {/* What this key is called at the provider, the page | ||
| 2914 | that mints it, and their own setup steps. The | ||
| 2915 | question this dialog used to ask without | ||
| 2916 | answering. */} | ||
| 2917 | <CredentialGuidancePanel | ||
| 2918 | displayUrl={integrationDisplayUrl} | ||
| 2919 | methodKind={method?.kind} | ||
| 2920 | /> | ||
| 2921 | |||
| 2897 | {isOAuth && method ? ( | 2922 | {isOAuth && method ? ( |
| 2898 | cimdActive ? ( | 2923 | cimdActive ? ( |
| 2899 | <div className="space-y-2 rounded-lg border border-ring/40 bg-accent/30 px-3 py-3"> | 2924 | <div className="space-y-2 rounded-lg border border-ring/40 bg-accent/30 px-3 py-3"> |
| @@ -142,10 +142,11 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) { · 141 unmodified lines | |||
| 142 | <div className="flex items-center justify-between"> | 142 | <div className="flex items-center justify-between"> |
| 143 | <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> | 143 | <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> |
| 144 | {detected ? <LockIcon className="size-3 shrink-0" aria-hidden /> : null} | 144 | {detected ? <LockIcon className="size-3 shrink-0" aria-hidden /> : null} |
| 145 | <span> | 145 | {/* Named by what it is. "Method 1" told the reader nothing |
| 146 | Method {index + 1} | 146 | except that there might be a Method 2, and next to a |
| 147 | {row.seedLabel ? ` · ${row.seedLabel}` : ""} | 147 | masked value it read as a locked credential field — |
| 148 | </span> | 148 | people tried to paste their key into it. */} |
| 149 | <span>{detected ? detectedMethodTitle(row) : `Method ${index + 1}`}</span> | ||
| 149 | </span> | 150 | </span> |
| 150 | <Button | 151 | <Button |
| 151 | type="button" | 152 | type="button" |
| @@ -181,6 +182,14 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) { · 29 unmodified lines | |||
| 181 | ); | 182 | ); |
| 182 | } | 183 | } |
| 183 | 184 | ||
| 185 | /** What a spec-detected method should be called: the credential it wants, plus | ||
| 186 | * the spec's own name for it when there is more than one to tell apart. */ | ||
| 187 | function detectedMethodTitle(row: AuthMethodRow): string { | ||
| 188 | const kind = row.value.kind; | ||
| 189 | const base = kind === "oauth" ? "OAuth" : kind === "apikey" ? "API key" : "No authentication"; | ||
| 190 | return row.seedLabel ? `${base} · ${row.seedLabel}` : base; | ||
| 191 | } | ||
| 192 | |||
| 184 | /** One read-only `label value` line, mono value, for the detected summary. */ | 193 | /** One read-only `label value` line, mono value, for the detected summary. */ |
| 185 | function SpecField(props: { readonly label: string; readonly value: string }) { | 194 | function SpecField(props: { readonly label: string; readonly value: string }) { |
| 186 | return ( | 195 | return ( |
| @@ -204,16 +213,11 @@ function DetectedMethodSummary(props: { · 17 unmodified lines | |||
| 204 | const { value, oauthMetadata } = props; | 213 | const { value, oauthMetadata } = props; |
| 205 | // Name the auth kind explicitly: a detection label like MCP's "Detected" | 214 | // Name the auth kind explicitly: a detection label like MCP's "Detected" |
| 206 | // doesn't say whether it's OAuth or an API key, so surface it here. | 215 | // doesn't say whether it's OAuth or an API key, so surface it here. |
| 207 | const kindLabel = | ||
| 208 | value.kind === "oauth" ? "OAuth" : value.kind === "apikey" ? "API key" : "No auth"; | ||
| 209 | return ( | 216 | return ( |
| 210 | <div className="space-y-2"> | 217 | <div className="space-y-2"> |
| 211 | <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground"> | ||
| 212 | {kindLabel} | ||
| 213 | </p> | ||
| 214 | <div | 218 | <div |
| 215 | aria-disabled | 219 | aria-disabled |
| 216 | className="cursor-not-allowed select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground" | 220 | className="select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground" |
| 217 | > | 221 | > |
| 218 | {value.kind === "none" && ( | 222 | {value.kind === "none" && ( |
| 219 | <p className="text-xs">No credential — tools are callable without an account.</p> | 223 | <p className="text-xs">No credential — tools are callable without an account.</p> |
| @@ -221,10 +225,13 @@ function DetectedMethodSummary(props: { · 1 unmodified line | |||
| 221 | 225 | ||
| 222 | {value.kind === "apikey" && | 226 | {value.kind === "apikey" && |
| 223 | (value.placements.length > 0 ? ( | 227 | (value.placements.length > 0 ? ( |
| 224 | <div className="flex flex-wrap gap-x-4 gap-y-1"> | 228 | <div className="space-y-1"> |
| 225 | {value.placements.map((placement, i: number) => ( | 229 | <p className="text-xs">This API takes a key, sent as:</p> |
| 226 | <PlacementLine key={i} placement={placement} /> | 230 | <div className="flex flex-wrap gap-x-4 gap-y-1"> |
| 227 | ))} | 231 | {value.placements.map((placement, i: number) => ( |
| 232 | <PlacementLine key={i} placement={placement} /> | ||
| 233 | ))} | ||
| 234 | </div> | ||
| 228 | </div> | 235 | </div> |
| 229 | ) : null)} | 236 | ) : null)} |
| 230 | 237 | ||
| @@ -246,7 +253,18 @@ function DetectedMethodSummary(props: { · 15 unmodified lines | |||
| 246 | ))} | 253 | ))} |
| 247 | </div> | 254 | </div> |
| 248 | 255 | ||
| 249 | <p className="text-[11px] text-muted-foreground">Pulled from spec. Remove to override.</p> | 256 | {/* The question this screen used to leave open. Nothing on the add form |
| 257 | takes a credential; the key is entered once the integration exists, | ||
| 258 | from its own page. Saying so is the difference between a confident | ||
| 259 | next click and hunting for a field that is not here. */} | ||
| 260 | <p className="text-[11px] text-muted-foreground"> | ||
| 261 | {/* "Declared by this API", not "read from the spec": GraphQL surfaces | ||
| 262 | have no spec — their methods arrive from the registry record — and | ||
| 263 | the claim that matters is the same either way. */} | ||
| 264 | {value.kind === "oauth" | ||
| 265 | ? "Declared by this API. You'll sign in after adding this integration." | ||
| 266 | : "Declared by this API. You'll enter your key after adding this integration."} | ||
| 267 | </p> | ||
| 250 | </div> | 268 | </div> |
| 251 | ); | 269 | ); |
| 252 | } | 270 | } |
| @@ -0,0 +1,168 @@ | |||
| 1 | import { useMemo, useState } from "react"; | ||
| 2 | import { ArrowUpRightIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react"; | ||
| 3 | import { getDomain } from "tldts"; | ||
| 4 | |||
| 5 | import { trackEvent } from "../api/analytics"; | ||
| 6 | import { useCredentialGuidance } from "../lib/integrations-sh-catalog"; | ||
| 7 | import { cn } from "../lib/utils"; | ||
| 8 | |||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | // What this key is, and where to get one. | ||
| 11 | // | ||
| 12 | // The registry records, per provider, what the credential is called, the page | ||
| 13 | // that mints it, and the provider's own setup steps. Asking someone for a key | ||
| 14 | // without any of that is the reason the connect form produced "Okay so what | ||
| 15 | // type of key do I need?" — the answer existed, one fetch away, and was thrown | ||
| 16 | // out on arrival. | ||
| 17 | // | ||
| 18 | // Strictly supplementary: the form works unchanged when the registry knows | ||
| 19 | // nothing, and this renders nothing at all rather than an empty frame. | ||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | |||
| 22 | /** The registry's setup text is markdown. Links, bold and inline code are all | ||
| 23 | * that occur in practice, so render those three rather than pull a markdown | ||
| 24 | * pipeline into a form. */ | ||
| 25 | function SetupText(props: { readonly text: string }) { | ||
| 26 | const nodes = useMemo( | ||
| 27 | () => | ||
| 28 | props.text | ||
| 29 | .split(/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`)/g) | ||
| 30 | .filter((chunk) => chunk.length > 0) | ||
| 31 | .map((chunk, index) => { | ||
| 32 | const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(chunk); | ||
| 33 | if (link) { | ||
| 34 | return ( | ||
| 35 | <a | ||
| 36 | key={index} | ||
| 37 | href={link[2]} | ||
| 38 | target="_blank" | ||
| 39 | rel="noreferrer" | ||
| 40 | className="font-medium text-foreground underline underline-offset-2" | ||
| 41 | > | ||
| 42 | {link[1]} | ||
| 43 | </a> | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | const bold = /^\*\*([^*]+)\*\*$/.exec(chunk); | ||
| 47 | if (bold) { | ||
| 48 | return ( | ||
| 49 | <span key={index} className="font-medium text-foreground"> | ||
| 50 | {bold[1]} | ||
| 51 | </span> | ||
| 52 | ); | ||
| 53 | } | ||
| 54 | const code = /^`([^`]+)`$/.exec(chunk); | ||
| 55 | if (code) { | ||
| 56 | return ( | ||
| 57 | <code key={index} className="font-mono text-[0.9em] text-foreground"> | ||
| 58 | {code[1]} | ||
| 59 | </code> | ||
| 60 | ); | ||
| 61 | } | ||
| 62 | return <span key={index}>{chunk}</span>; | ||
| 63 | }), | ||
| 64 | [props.text], | ||
| 65 | ); | ||
| 66 | return <p className="text-xs leading-relaxed text-muted-foreground">{nodes}</p>; | ||
| 67 | } | ||
| 68 | |||
| 69 | export function CredentialGuidancePanel(props: { | ||
| 70 | /** The integration's display URL; the domain is read from it. */ | ||
| 71 | readonly displayUrl: string | undefined; | ||
| 72 | /** "oauth" hides key guidance — there is no key to fetch. */ | ||
| 73 | readonly methodKind: string | undefined; | ||
| 74 | }) { | ||
| 75 | const domain = useMemo( | ||
| 76 | () => (props.displayUrl ? getDomain(props.displayUrl) : null), | ||
| 77 | [props.displayUrl], | ||
| 78 | ); | ||
| 79 | const guidance = useCredentialGuidance(domain); | ||
| 80 | |||
| 81 | const relevant = useMemo(() => { | ||
| 82 | if (props.methodKind === "oauth") { | ||
| 83 | return guidance.find((entry) => entry.type.startsWith("oauth")); | ||
| 84 | } | ||
| 85 | // Anything that is not an OAuth grant is the key the form is asking for. | ||
| 86 | return guidance.find((entry) => !entry.type.startsWith("oauth")) ?? guidance[0]; | ||
| 87 | }, [guidance, props.methodKind]); | ||
| 88 | |||
| 89 | if (!relevant || (!relevant.generateUrl && !relevant.setup)) return null; | ||
| 90 | |||
| 91 | return ( | ||
| 92 | <div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 px-3 py-2.5"> | ||
| 93 | <div className="flex items-baseline justify-between gap-3"> | ||
| 94 | <span className="text-xs font-medium text-foreground">{relevant.label}</span> | ||
| 95 | {relevant.generateUrl ? ( | ||
| 96 | <a | ||
| 97 | href={relevant.generateUrl} | ||
| 98 | target="_blank" | ||
| 99 | rel="noreferrer" | ||
| 100 | className="flex shrink-0 items-center gap-1 text-xs font-medium text-foreground underline underline-offset-2" | ||
| 101 | > | ||
| 102 | Get a key | ||
| 103 | <ArrowUpRightIcon className="size-3" aria-hidden /> | ||
| 104 | </a> | ||
| 105 | ) : null} | ||
| 106 | </div> | ||
| 107 | {relevant.setup ? <SetupText text={relevant.setup} /> : null} | ||
| 108 | {/* Provenance, stated plainly: this text is machine-written registry | ||
| 109 | data, not the provider's own docs — the reader should weight it | ||
| 110 | accordingly (menus move, prefixes change). The thumbs are the | ||
| 111 | accuracy loop: votes land in analytics keyed by domain, so wrong | ||
| 112 | guidance is findable instead of silently misleading. */} | ||
| 113 | <div className="flex items-center justify-between gap-3 pt-0.5"> | ||
| 114 | <p className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground/60"> | ||
| 115 | AI-generated guidance via{" "} | ||
| 116 | <a | ||
| 117 | href={`https://integrations.sh/${domain ?? ""}`} | ||
| 118 | target="_blank" | ||
| 119 | rel="noreferrer" | ||
| 120 | className="underline underline-offset-2 hover:text-muted-foreground" | ||
| 121 | > | ||
| 122 | integrations.sh | ||
| 123 | </a> | ||
| 124 | </p> | ||
| 125 | <GuidanceVote domain={domain ?? ""} label={relevant.label} /> | ||
| 126 | </div> | ||
| 127 | </div> | ||
| 128 | ); | ||
| 129 | } | ||
| 130 | |||
| 131 | /** One vote per render of the panel; the pair collapses to the chosen thumb | ||
| 132 | * so a second opinion means reopening, not flip-flopping the metric. */ | ||
| 133 | function GuidanceVote(props: { readonly domain: string; readonly label: string }) { | ||
| 134 | const [voted, setVoted] = useState<"up" | "down" | null>(null); | ||
| 135 | const vote = (value: "up" | "down") => { | ||
| 136 | if (voted) return; | ||
| 137 | setVoted(value); | ||
| 138 | trackEvent("credential_guidance_rated", { | ||
| 139 | domain: props.domain, | ||
| 140 | credential_label: props.label, | ||
| 141 | vote: value, | ||
| 142 | }); | ||
| 143 | }; | ||
| 144 | return ( | ||
| 145 | <span className="flex shrink-0 items-center gap-1"> | ||
| 146 | {(["up", "down"] as const).map((value) => { | ||
| 147 | const Icon = value === "up" ? ThumbsUpIcon : ThumbsDownIcon; | ||
| 148 | if (voted && voted !== value) return null; | ||
| 149 | return ( | ||
| 150 | // oxlint-disable-next-line react/forbid-elements -- an icon chip, not a Button variant | ||
| 151 | <button | ||
| 152 | key={value} | ||
| 153 | type="button" | ||
| 154 | aria-label={value === "up" ? "Guidance was accurate" : "Guidance was wrong"} | ||
| 155 | disabled={voted !== null} | ||
| 156 | onClick={() => vote(value)} | ||
| 157 | className={cn( | ||
| 158 | "rounded p-1 text-muted-foreground/50 transition-colors", | ||
| 159 | voted === value ? "text-foreground" : "hover:bg-muted hover:text-muted-foreground", | ||
| 160 | )} | ||
| 161 | > | ||
| 162 | <Icon className="size-3" aria-hidden /> | ||
| 163 | </button> | ||
| 164 | ); | ||
| 165 | })} | ||
| 166 | </span> | ||
| 167 | ); | ||
| 168 | } | ||
| @@ -209,7 +209,6 @@ export function IntegrationFavicon({ · 208 unmodified lines | |||
| 209 | alt="" | 209 | alt="" |
| 210 | width={size} | 210 | width={size} |
| 211 | height={size} | 211 | height={size} |
| 212 | loading="lazy" | ||
| 213 | onError={() => | 212 | onError={() => |
| 214 | setFailedSrcs((current) => | 213 | setFailedSrcs((current) => |
| 215 | current.includes(failedCandidate) ? current : [...current, failedCandidate], | 214 | current.includes(failedCandidate) ? current : [...current, failedCandidate], |
| @@ -37,6 +37,7 @@ export const CONSOLE_ROUTE_PATHS = [ · 36 unmodified lines | |||
| 37 | "/connect/$integrationSlug", | 37 | "/connect/$integrationSlug", |
| 38 | "/integrations/$namespace", | 38 | "/integrations/$namespace", |
| 39 | "/integrations/add/$pluginKey", | 39 | "/integrations/add/$pluginKey", |
| 40 | "/integrations/browse", | ||
| 40 | "/policies", | 41 | "/policies", |
| 41 | "/secrets", | 42 | "/secrets", |
| 42 | "/tools", | 43 | "/tools", |
| @@ -78,6 +79,7 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 35 unmodified lines | |||
| 78 | "/integrations/add/$pluginKey", | 79 | "/integrations/add/$pluginKey", |
| 79 | route("/integrations/add/$pluginKey", file("integrations.add.$pluginKey.tsx")), | 80 | route("/integrations/add/$pluginKey", file("integrations.add.$pluginKey.tsx")), |
| 80 | ], | 81 | ], |
| 82 | ["/integrations/browse", route("/integrations/browse", file("integrations.browse.tsx"))], | ||
| 81 | ["/policies", route("/policies", file("policies.tsx"))], | 83 | ["/policies", route("/policies", file("policies.tsx"))], |
| 82 | ["/secrets", route("/secrets", file("secrets.tsx"))], | 84 | ["/secrets", route("/secrets", file("secrets.tsx"))], |
| 83 | ["/tools", route("/tools", file("tools.tsx"))], | 85 | ["/tools", route("/tools", file("tools.tsx"))], |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import type { AuthMethodDescriptor } from "@executor-js/sdk/shared"; | 2 | import type { AuthMethodDescriptor } from "@executor-js/sdk/shared"; |
| 3 | 3 | ||
| 4 | import { authMethodsFromDescriptors } from "./auth-placements"; | 4 | import { authMethodsFromDescriptors, placementFromHeaderPattern } from "./auth-placements"; |
| 5 | 5 | ||
| 6 | describe("authMethodsFromDescriptors", () => { | 6 | describe("authMethodsFromDescriptors", () => { |
| 7 | it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => { | 7 | it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => { |
| @@ -101,3 +101,25 @@ describe("authMethodsFromDescriptors", () => { · 93 unmodified lines | |||
| 101 | expect(authMethodsFromDescriptors([])).toEqual([]); | 101 | expect(authMethodsFromDescriptors([])).toEqual([]); |
| 102 | }); | 102 | }); |
| 103 | }); | 103 | }); |
| 104 | |||
| 105 | describe("placementFromHeaderPattern", () => { | ||
| 106 | it("keeps a Bearer prefix, space included", () => { | ||
| 107 | expect(placementFromHeaderPattern("Authorization: Bearer {token}")).toEqual({ | ||
| 108 | carrier: "header", | ||
| 109 | name: "Authorization", | ||
| 110 | prefix: "Bearer ", | ||
| 111 | }); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("an empty prefix is meaningful: Linear's keys take no Bearer", () => { | ||
| 115 | expect(placementFromHeaderPattern("Authorization: {api_key}")).toEqual({ | ||
| 116 | carrier: "header", | ||
| 117 | name: "Authorization", | ||
| 118 | prefix: "", | ||
| 119 | }); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it("rejects text that is not a header pattern", () => { | ||
| 123 | expect(placementFromHeaderPattern("just some prose")).toBeNull(); | ||
| 124 | }); | ||
| 125 | }); | ||
| @@ -40,6 +40,22 @@ export interface Placement { · 39 unmodified lines | |||
| 40 | /** A fresh, empty header placement — the default first row in an editor. */ | 40 | /** A fresh, empty header placement — the default first row in an editor. */ |
| 41 | export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", prefix: "" }); | 41 | export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", prefix: "" }); |
| 42 | 42 | ||
| 43 | /** Parses a registry header pattern — `"Authorization: Bearer {token}"` — into | ||
| 44 | * a placement. The literal text between the colon and the `{variable}` is the | ||
| 45 | * prefix, and its ABSENCE is load-bearing: `"Authorization: {api_key}"` is how | ||
| 46 | * the registry says Linear's personal keys take no Bearer prefix. */ | ||
| 47 | export function placementFromHeaderPattern(pattern: string): Placement | null { | ||
| 48 | const match = /^([A-Za-z0-9-]+):\s*(.*)$/.exec(pattern.trim()); | ||
| 49 | if (!match) return null; | ||
| 50 | const rest = match[2] ?? ""; | ||
| 51 | const brace = rest.indexOf("{"); | ||
| 52 | return { | ||
| 53 | carrier: "header", | ||
| 54 | name: match[1] ?? "", | ||
| 55 | prefix: brace >= 0 ? rest.slice(0, brace) : "", | ||
| 56 | }; | ||
| 57 | } | ||
| 58 | |||
| 43 | /** What an auth method is, presentationally. `kind` drives the credential UI: | 59 | /** What an auth method is, presentationally. `kind` drives the credential UI: |
| 44 | * `oauth` shows a Connect button, `none` creates a connection with no | 60 | * `oauth` shows a Connect button, `none` creates a connection with no |
| 45 | * credential inputs, and apikey/custom fill secrets across `placements`. | 61 | * credential inputs, and apikey/custom fill secrets across `placements`. |
| @@ -9,7 +9,7 @@ · 8 unmodified lines | |||
| 9 | // GraphQL endpoint). | 9 | // GraphQL endpoint). |
| 10 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 11 | 11 | ||
| 12 | import { useEffect, useRef, useState } from "react"; | 12 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 13 | import * as Data from "effect/Data"; | 13 | import * as Data from "effect/Data"; |
| 14 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Exit from "effect/Exit"; | 15 | import * as Exit from "effect/Exit"; |
| @@ -28,10 +28,49 @@ export type CatalogKind = (typeof CONNECTABLE_KINDS)[number]; · 12 unmodified lines | |||
| 28 | const isConnectableKind = (kind: string): kind is CatalogKind => | 28 | const isConnectableKind = (kind: string): kind is CatalogKind => |
| 29 | (CONNECTABLE_KINDS as readonly string[]).includes(kind); | 29 | (CONNECTABLE_KINDS as readonly string[]).includes(kind); |
| 30 | 30 | ||
| 31 | /** One connectable surface of a domain, as the registry reports it. | ||
| 32 | * | ||
| 33 | * `slug` is the registry's stable identifier for THIS surface, and it is the | ||
| 34 | * namespace the add flow seeds — which makes it the honest answer to "have I | ||
| 35 | * added this already?". The domain is not: a vendor's surfaces can live on | ||
| 36 | * other hosts entirely (GitHub's MCP server is on api.githubcopilot.com), and | ||
| 37 | * one domain routinely offers several surfaces that are separate | ||
| 38 | * integrations. */ | ||
| 39 | export interface CatalogSurface { | ||
| 40 | readonly kind: CatalogKind; | ||
| 41 | readonly slug: string; | ||
| 42 | /** What to point the add flow at. Absent when the registry has no | ||
| 43 | * machine-readable locator, in which case the surface document still has to | ||
| 44 | * be fetched on click. */ | ||
| 45 | readonly url?: string; | ||
| 46 | /** A hand-picked product mark (Google Calendar's own logo rather than the | ||
| 47 | * generic G), present only on curated surfaces. */ | ||
| 48 | readonly icon?: string; | ||
| 49 | /** How to authenticate, for surfaces whose connect target cannot describe | ||
| 50 | * it itself — a GraphQL endpoint has no spec document, so the registry is | ||
| 51 | * the only carrier of facts like Linear's no-Bearer-prefix header. */ | ||
| 52 | readonly auth?: { | ||
| 53 | readonly kind?: string; | ||
| 54 | /** Header pattern, e.g. "Authorization: Bearer {token}". */ | ||
| 55 | readonly header?: string; | ||
| 56 | readonly note?: string; | ||
| 57 | }; | ||
| 58 | /** RFC 6902 JSON Patch the registry says to apply to the fetched spec — | ||
| 59 | * how a vendor's published document gets improved without hosting a fork | ||
| 60 | * (e.g. Neon's console session cookies posing as security schemes). */ | ||
| 61 | readonly specOverrides?: readonly unknown[]; | ||
| 62 | } | ||
| 63 | |||
| 31 | export interface CatalogSearchEntry { | 64 | export interface CatalogSearchEntry { |
| 32 | readonly domain: string; | 65 | readonly domain: string; |
| 66 | /** The product's display name, when the row is a named product rather than | ||
| 67 | * a bare domain — "Outlook Mail" on graph.microsoft.com. */ | ||
| 68 | readonly name?: string; | ||
| 33 | readonly description: string; | 69 | readonly description: string; |
| 34 | readonly kinds: readonly CatalogKind[]; | 70 | readonly kinds: readonly CatalogKind[]; |
| 71 | /** Present on registries new enough to report it; absent responses fall back | ||
| 72 | * to resolving the surface document per click. */ | ||
| 73 | readonly surfaces?: readonly CatalogSurface[]; | ||
| 35 | } | 74 | } |
| 36 | 75 | ||
| 37 | export const catalogLogoUrl = (domain: string, size: number): string => | 76 | export const catalogLogoUrl = (domain: string, size: number): string => |
| @@ -44,7 +83,14 @@ class CatalogRequestError extends Data.TaggedError("CatalogRequestError")<{ · 6 unmodified lines | |||
| 44 | const fetchCatalogJson = (url: URL): Effect.Effect<unknown, CatalogRequestError> => | 83 | const fetchCatalogJson = (url: URL): Effect.Effect<unknown, CatalogRequestError> => |
| 45 | Effect.gen(function* () { | 84 | Effect.gen(function* () { |
| 46 | const response = yield* Effect.tryPromise({ | 85 | const response = yield* Effect.tryPromise({ |
| 47 | try: () => fetch(url), | 86 | // ALWAYS REVALIDATE. The catalog is a live service and its corrections |
| 87 | // must reach the console immediately, but the browser will honour | ||
| 88 | // whatever max-age the response carried when it was stored — a stale | ||
| 89 | // entry with a long TTL kept showing a domain that had been merged away | ||
| 90 | // and an MCP endpoint that does not exist, hours after the fix shipped. | ||
| 91 | // `no-cache` still sends the conditional request, so a 304 costs nothing | ||
| 92 | // when nothing has changed. | ||
| 93 | try: () => fetch(url, { cache: "no-cache" }), | ||
| 48 | catch: () => new CatalogRequestError({ message: `Failed to reach ${url.host}.` }), | 94 | catch: () => new CatalogRequestError({ message: `Failed to reach ${url.host}.` }), |
| 49 | }); | 95 | }); |
| 50 | if (!response.ok) { | 96 | if (!response.ok) { |
| @@ -66,34 +112,103 @@ const SearchResponse = Schema.Struct({ · 15 unmodified lines | |||
| 66 | results: Schema.Array( | 112 | results: Schema.Array( |
| 67 | Schema.Struct({ | 113 | Schema.Struct({ |
| 68 | domain: Schema.String, | 114 | domain: Schema.String, |
| 115 | name: Schema.optional(Schema.String), | ||
| 69 | description: Schema.String, | 116 | description: Schema.String, |
| 70 | kinds: Schema.Array(Schema.String), | 117 | kinds: Schema.Array(Schema.String), |
| 118 | surfaces: Schema.optional( | ||
| 119 | Schema.Array( | ||
| 120 | Schema.Struct({ | ||
| 121 | kind: Schema.String, | ||
| 122 | slug: Schema.String, | ||
| 123 | url: Schema.optional(Schema.String), | ||
| 124 | icon: Schema.optional(Schema.String), | ||
| 125 | auth: Schema.optional( | ||
| 126 | Schema.Struct({ | ||
| 127 | kind: Schema.optional(Schema.String), | ||
| 128 | header: Schema.optional(Schema.String), | ||
| 129 | note: Schema.optional(Schema.String), | ||
| 130 | }), | ||
| 131 | ), | ||
| 132 | specOverrides: Schema.optional(Schema.Array(Schema.Unknown)), | ||
| 133 | }), | ||
| 134 | ), | ||
| 135 | ), | ||
| 71 | }), | 136 | }), |
| 72 | ), | 137 | ), |
| 73 | }); | 138 | }); |
| 74 | const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse); | 139 | const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse); |
| 75 | 140 | ||
| 141 | export interface CatalogSearchPage { | ||
| 142 | readonly entries: readonly CatalogSearchEntry[]; | ||
| 143 | /** Rows the registry returned BEFORE the connectable-kind filter. Paging | ||
| 144 | * exhaustion must key on this: a page can be full at the registry and | ||
| 145 | * shrink here (CLI-only rows are dropped), and judging "no more pages" by | ||
| 146 | * the filtered length ended the infinite scroll mid-catalog. */ | ||
| 147 | readonly rawCount: number; | ||
| 148 | } | ||
| 149 | |||
| 150 | export const parseCatalogSearchPage = (payload: unknown): CatalogSearchPage => ({ | ||
| 151 | entries: parseCatalogSearch(payload), | ||
| 152 | rawCount: Option.match(decodeSearchResponse(payload), { | ||
| 153 | onNone: () => 0, | ||
| 154 | onSome: ({ results }) => results.length, | ||
| 155 | }), | ||
| 156 | }); | ||
| 157 | |||
| 76 | export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntry[] => | 158 | export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntry[] => |
| 77 | Option.match(decodeSearchResponse(payload), { | 159 | Option.match(decodeSearchResponse(payload), { |
| 78 | onNone: () => [], | 160 | onNone: () => [], |
| 79 | onSome: ({ results }) => | 161 | onSome: ({ results }) => |
| 80 | results | 162 | results |
| 81 | .map((entry) => ({ | 163 | .map((entry) => { |
| 82 | domain: entry.domain, | 164 | const surfaces = (entry.surfaces ?? []).flatMap((surface): readonly CatalogSurface[] => |
| 83 | description: entry.description, | 165 | isConnectableKind(surface.kind) |
| 84 | kinds: entry.kinds.filter(isConnectableKind), | 166 | ? [ |
| 85 | })) | 167 | { |
| 168 | kind: surface.kind, | ||
| 169 | slug: surface.slug, | ||
| 170 | ...(surface.url ? { url: surface.url } : {}), | ||
| 171 | ...(surface.icon ? { icon: surface.icon } : {}), | ||
| 172 | ...(surface.auth ? { auth: surface.auth } : {}), | ||
| 173 | ...(surface.specOverrides && surface.specOverrides.length > 0 | ||
| 174 | ? { specOverrides: surface.specOverrides } | ||
| 175 | : {}), | ||
| 176 | }, | ||
| 177 | ] | ||
| 178 | : [], | ||
| 179 | ); | ||
| 180 | return { | ||
| 181 | domain: entry.domain, | ||
| 182 | ...(entry.name && entry.name !== entry.domain ? { name: entry.name } : {}), | ||
| 183 | description: entry.description, | ||
| 184 | kinds: entry.kinds.filter(isConnectableKind), | ||
| 185 | ...(surfaces.length > 0 ? { surfaces } : {}), | ||
| 186 | }; | ||
| 187 | }) | ||
| 86 | .filter((entry) => entry.kinds.length > 0), | 188 | .filter((entry) => entry.kinds.length > 0), |
| 87 | }); | 189 | }); |
| 88 | 190 | ||
| 191 | export interface CatalogQuery { | ||
| 192 | /** Free text. EMPTY IS MEANINGFUL: the registry answers an empty query with | ||
| 193 | * its popularity-ordered head, which is what a browse view wants. */ | ||
| 194 | readonly query: string; | ||
| 195 | /** Restrict to one surface kind. Omitted means every connectable kind. */ | ||
| 196 | readonly kind?: CatalogKind; | ||
| 197 | /** The registry caps this at 100. */ | ||
| 198 | readonly limit?: number; | ||
| 199 | /** Ranked results to skip — how the picker pages an endless list. */ | ||
| 200 | readonly offset?: number; | ||
| 201 | } | ||
| 202 | |||
| 89 | export const searchCatalog = ( | 203 | export const searchCatalog = ( |
| 90 | query: string, | 204 | input: CatalogQuery, |
| 91 | limit = 10, | 205 | ): Effect.Effect<CatalogSearchPage, CatalogRequestError> => { |
| 92 | ): Effect.Effect<readonly CatalogSearchEntry[], CatalogRequestError> => { | ||
| 93 | const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN); | 206 | const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN); |
| 94 | url.searchParams.set("q", query); | 207 | url.searchParams.set("q", input.query); |
| 95 | url.searchParams.set("limit", String(limit)); | 208 | url.searchParams.set("limit", String(input.limit ?? 10)); |
| 96 | return Effect.map(fetchCatalogJson(url), parseCatalogSearch); | 209 | if (input.kind) url.searchParams.set("kind", input.kind); |
| 210 | if (input.offset) url.searchParams.set("offset", String(input.offset)); | ||
| 211 | return Effect.map(fetchCatalogJson(url), parseCatalogSearchPage); | ||
| 97 | }; | 212 | }; |
| 98 | 213 | ||
| 99 | // --------------------------------------------------------------------------- | 214 | // --------------------------------------------------------------------------- |
| @@ -135,18 +250,35 @@ const connectUrlOf = (surface: SurfaceRecord, kind: CatalogKind): string | undef · 35 unmodified lines | |||
| 135 | ? surface.url | 250 | ? surface.url |
| 136 | : undefined; | 251 | : undefined; |
| 137 | 252 | ||
| 253 | const normalizedProductToken = (value: string): string => | ||
| 254 | value.toLowerCase().replace(/[^a-z0-9]/g, ""); | ||
| 255 | |||
| 138 | export const pickConnectTarget = ( | 256 | export const pickConnectTarget = ( |
| 139 | payload: unknown, | 257 | payload: unknown, |
| 140 | kind: CatalogKind, | 258 | kind: CatalogKind, |
| 259 | preferredName?: string, | ||
| 141 | ): CatalogConnectTarget | undefined => | 260 | ): CatalogConnectTarget | undefined => |
| 142 | Option.match(decodeSurfaceDocument(payload), { | 261 | Option.match(decodeSurfaceDocument(payload), { |
| 143 | onNone: () => undefined, | 262 | onNone: () => undefined, |
| 144 | onSome: ({ surfaces }) => { | 263 | onSome: ({ surfaces }) => { |
| 145 | for (const surface of surfaces) { | 264 | // One domain can carry several same-kind products (Google Photos |
| 265 | // Library and Picker share photos.google.com). When the caller names | ||
| 266 | // the product, a slug whose normalized form matches wins; first-of-kind | ||
| 267 | // is only the answer when nothing identifies the product. | ||
| 268 | const usable = surfaces.flatMap((surface) => { | ||
| 146 | const url = connectUrlOf(surface, kind); | 269 | const url = connectUrlOf(surface, kind); |
| 147 | if (url) return { kind, url, ...(surface.slug ? { slug: surface.slug } : {}) }; | 270 | return url |
| 271 | ? [{ kind, url, ...(surface.slug ? { slug: surface.slug } : {}) } as CatalogConnectTarget] | ||
| 272 | : []; | ||
| 273 | }); | ||
| 274 | if (preferredName) { | ||
| 275 | const token = normalizedProductToken(preferredName); | ||
| 276 | const named = usable.find( | ||
| 277 | (target) => target.slug && normalizedProductToken(target.slug) === token, | ||
| 278 | ); | ||
| 279 | if (named) return named; | ||
| 148 | } | 280 | } |
| 149 | return undefined; | 281 | return usable[0]; |
| 150 | }, | 282 | }, |
| 151 | }); | 283 | }); |
| 152 | 284 | ||
| @@ -155,11 +287,12 @@ export const pickConnectTarget = ( · 2 unmodified lines | |||
| 155 | export const resolveConnectTarget = ( | 287 | export const resolveConnectTarget = ( |
| 156 | domain: string, | 288 | domain: string, |
| 157 | kinds: readonly CatalogKind[], | 289 | kinds: readonly CatalogKind[], |
| 290 | preferredName?: string, | ||
| 158 | ): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => { | 291 | ): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => { |
| 159 | const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN); | 292 | const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN); |
| 160 | return Effect.map(fetchCatalogJson(url), (payload) => { | 293 | return Effect.map(fetchCatalogJson(url), (payload) => { |
| 161 | for (const kind of kinds) { | 294 | for (const kind of kinds) { |
| 162 | const target = pickConnectTarget(payload, kind); | 295 | const target = pickConnectTarget(payload, kind, preferredName); |
| 163 | if (target) return target; | 296 | if (target) return target; |
| 164 | } | 297 | } |
| 165 | return undefined; | 298 | return undefined; |
| @@ -172,16 +305,27 @@ export const resolveConnectTarget = ( · 6 unmodified lines | |||
| 172 | 305 | ||
| 173 | /** Domains already represented by a loaded plugin's presets, so the catalog | 306 | /** Domains already represented by a loaded plugin's presets, so the catalog |
| 174 | * section doesn't repeat what the preset list above it already shows. */ | 307 | * section doesn't repeat what the preset list above it already shows. */ |
| 308 | /** The domain a preset represents, read from its integrations.sh logo URL | ||
| 309 | * first (which names the domain outright) and its spec/endpoint URL second. */ | ||
| 310 | export const presetDomain = (preset: { | ||
| 311 | readonly icon?: string; | ||
| 312 | readonly url?: string; | ||
| 313 | }): string | null => { | ||
| 314 | for (const candidate of [preset.icon, preset.url]) { | ||
| 315 | if (!candidate) continue; | ||
| 316 | const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate); | ||
| 317 | const domain = logoMatch?.[1] ?? getDomain(candidate); | ||
| 318 | if (domain) return domain; | ||
| 319 | } | ||
| 320 | return null; | ||
| 321 | }; | ||
| 322 | |||
| 175 | export const presetDomains = (plugins: readonly IntegrationPlugin[]): ReadonlySet<string> => { | 323 | export const presetDomains = (plugins: readonly IntegrationPlugin[]): ReadonlySet<string> => { |
| 176 | const domains = new Set<string>(); | 324 | const domains = new Set<string>(); |
| 177 | for (const plugin of plugins) { | 325 | for (const plugin of plugins) { |
| 178 | for (const preset of plugin.presets ?? []) { | 326 | for (const preset of plugin.presets ?? []) { |
| 179 | for (const candidate of [preset.icon, preset.url]) { | 327 | const domain = presetDomain(preset); |
| 180 | if (!candidate) continue; | 328 | if (domain) domains.add(domain); |
| 181 | const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate); | ||
| 182 | const domain = logoMatch?.[1] ?? getDomain(candidate); | ||
| 183 | if (domain) domains.add(domain); | ||
| 184 | } | ||
| 185 | } | 329 | } |
| 186 | } | 330 | } |
| 187 | return domains; | 331 | return domains; |
| @@ -206,6 +350,9 @@ export const filterCatalogEntries = ( · 18 unmodified lines | |||
| 206 | .map((entry) => ({ | 350 | .map((entry) => ({ |
| 207 | ...entry, | 351 | ...entry, |
| 208 | kinds: entry.kinds.filter((kind) => opts.availableKinds.includes(kind)), | 352 | kinds: entry.kinds.filter((kind) => opts.availableKinds.includes(kind)), |
| 353 | ...(entry.surfaces | ||
| 354 | ? { surfaces: entry.surfaces.filter((s) => opts.availableKinds.includes(s.kind)) } | ||
| 355 | : {}), | ||
| 209 | })) | 356 | })) |
| 210 | .filter((entry) => entry.kinds.length > 0); | 357 | .filter((entry) => entry.kinds.length > 0); |
| 211 | 358 | ||
| @@ -213,43 +360,293 @@ export const filterCatalogEntries = ( · 1 unmodified line | |||
| 213 | // Hook — debounced search with an in-session response cache | 360 | // Hook — debounced search with an in-session response cache |
| 214 | // --------------------------------------------------------------------------- | 361 | // --------------------------------------------------------------------------- |
| 215 | 362 | ||
| 216 | const SEARCH_DEBOUNCE_MS = 250; | 363 | const SEARCH_DEBOUNCE_MS = 150; |
| 364 | /** Free-text queries wait for a second character; a browse (empty query) does | ||
| 365 | * not, because there is nothing to narrow. */ | ||
| 217 | const MIN_QUERY_LENGTH = 2; | 366 | const MIN_QUERY_LENGTH = 2; |
| 218 | const searchCache = new Map<string, readonly CatalogSearchEntry[]>(); | 367 | const BROWSE_LIMIT = 60; |
| 219 | 368 | ||
| 220 | export interface CatalogSearchState { | 369 | export interface CatalogSearchState { |
| 221 | readonly entries: readonly CatalogSearchEntry[]; | 370 | readonly entries: readonly CatalogSearchEntry[]; |
| 222 | readonly loading: boolean; | 371 | readonly loading: boolean; |
| 372 | /** The entries belong to a previous query, held over while the current one | ||
| 373 | * loads. Callers must not render them as if they answered the live query. */ | ||
| 374 | readonly stale: boolean; | ||
| 375 | /** The registry could not be reached. The rest of the page still works, so | ||
| 376 | * this is a section-level notice rather than a page error. */ | ||
| 377 | readonly failed: boolean; | ||
| 378 | /** The last page came back full, so more rows likely exist. */ | ||
| 379 | readonly hasMore: boolean; | ||
| 380 | /** A further page is being fetched and appended. */ | ||
| 381 | readonly loadingMore: boolean; | ||
| 382 | /** Fetch the next page. Safe to call repeatedly: no-op while a page is in | ||
| 383 | * flight or once the catalog is exhausted. */ | ||
| 384 | readonly loadMore: () => void; | ||
| 223 | } | 385 | } |
| 224 | 386 | ||
| 225 | export function useCatalogSearch(rawQuery: string): CatalogSearchState { | 387 | const cacheKey = (input: CatalogQuery): string => |
| 226 | const query = rawQuery.trim().toLowerCase(); | 388 | `${input.kind ?? "all"}:${input.limit ?? 0}:${input.query}`; |
| 227 | const [state, setState] = useState<CatalogSearchState>({ entries: [], loading: false }); | 389 | /** Responses are cached for as long as the registry says they are fresh, and |
| 390 | * no longer. The first version of this cache had no expiry at all, so a | ||
| 391 | * session that searched once kept that answer for its whole life — a registry | ||
| 392 | * correction could ship and the console would keep showing the old catalog | ||
| 393 | * until the tab was closed. */ | ||
| 394 | const SEARCH_TTL_MS = 60_000; | ||
| 395 | const searchCache = new Map< | ||
| 396 | string, | ||
| 397 | { readonly at: number; readonly entries: readonly CatalogSearchEntry[] } | ||
| 398 | >(); | ||
| 399 | |||
| 400 | /** | ||
| 401 | * Browse or search the public registry. | ||
| 402 | * | ||
| 403 | * One hook for both because they are the same request with a different `q`: | ||
| 404 | * an empty query returns the popularity-ordered head, a non-empty one filters | ||
| 405 | * it. Search stays server-side and edge-cached, so no view ever downloads the | ||
| 406 | * multi-thousand-entry catalog. | ||
| 407 | */ | ||
| 408 | export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState { | ||
| 409 | const query = input.query.trim().toLowerCase(); | ||
| 410 | const kind = input.kind; | ||
| 411 | const limit = input.limit ?? BROWSE_LIMIT; | ||
| 412 | const requestKeyOf = (forQuery: string) => | ||
| 413 | cacheKey({ query: forQuery, limit, ...(kind ? { kind } : {}) }); | ||
| 414 | const requestKey = requestKeyOf(query); | ||
| 415 | const [state, setState] = useState<{ | ||
| 416 | readonly entries: readonly CatalogSearchEntry[]; | ||
| 417 | /** The request the entries belong to. Held-over results from a previous | ||
| 418 | * query are DELIBERATE (no empty flash between keystrokes), but they | ||
| 419 | * must be knowable as stale — rendering them unmarked is how a | ||
| 420 | * "calendar" search showed Gmail cards. */ | ||
| 421 | readonly forKey: string; | ||
| 422 | readonly loading: boolean; | ||
| 423 | readonly failed: boolean; | ||
| 424 | }>({ entries: [], forKey: requestKey, loading: true, failed: false }); | ||
| 228 | const generation = useRef(0); | 425 | const generation = useRef(0); |
| 426 | // Pages beyond the first, keyed to the request they extend so a keystroke | ||
| 427 | // discards them with the first page rather than leaking into new results. | ||
| 428 | const [more, setMore] = useState<{ | ||
| 429 | readonly key: string; | ||
| 430 | readonly entries: readonly CatalogSearchEntry[]; | ||
| 431 | /** RAW rows consumed by extra pages — the server pages by raw index, and | ||
| 432 | * the connectable-kind filter makes filtered counts useless as offsets. */ | ||
| 433 | readonly rawConsumed: number; | ||
| 434 | readonly loadingMore: boolean; | ||
| 435 | readonly exhausted: boolean; | ||
| 436 | }>({ key: requestKey, entries: [], rawConsumed: 0, loadingMore: false, exhausted: false }); | ||
| 437 | |||
| 438 | useEffect(() => { | ||
| 439 | setMore({ key: requestKey, entries: [], rawConsumed: 0, loadingMore: false, exhausted: false }); | ||
| 440 | // requestKey is derived from (query, kind, limit) — same trigger set. | ||
| 441 | // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| 442 | }, [query, kind, limit]); | ||
| 229 | 443 | ||
| 230 | useEffect(() => { | 444 | useEffect(() => { |
| 231 | const requestId = ++generation.current; | 445 | const requestId = ++generation.current; |
| 232 | if (query.length < MIN_QUERY_LENGTH) { | 446 | // A partial word is not yet a query; leave the previous results in place |
| 233 | setState({ entries: [], loading: false }); | 447 | // rather than flashing an empty list between keystrokes. |
| 448 | if (query.length > 0 && query.length < MIN_QUERY_LENGTH) { | ||
| 449 | // Held entries keep their own forKey, so the hook reports them stale. | ||
| 450 | setState((previous) => ({ ...previous, loading: false })); | ||
| 234 | return; | 451 | return; |
| 235 | } | 452 | } |
| 236 | const cached = searchCache.get(query); | 453 | const request: CatalogQuery = { query, limit, ...(kind ? { kind } : {}) }; |
| 237 | if (cached) { | 454 | const cached = searchCache.get(cacheKey(request)); |
| 238 | setState({ entries: cached, loading: false }); | 455 | if (cached && Date.now() - cached.at < SEARCH_TTL_MS) { |
| 456 | setState({ | ||
| 457 | entries: cached.entries, | ||
| 458 | forKey: cacheKey(request), | ||
| 459 | loading: false, | ||
| 460 | failed: false, | ||
| 461 | }); | ||
| 239 | return; | 462 | return; |
| 240 | } | 463 | } |
| 241 | setState((previous) => ({ ...previous, loading: true })); | 464 | setState((previous) => ({ ...previous, loading: true })); |
| 242 | const timer = setTimeout(() => { | 465 | const timer = setTimeout( |
| 243 | void Effect.runPromiseExit(searchCatalog(query)).then((exit) => { | 466 | () => { |
| 244 | if (Exit.isSuccess(exit)) searchCache.set(query, exit.value); | 467 | void Effect.runPromiseExit(searchCatalog(request)).then((exit) => { |
| 245 | if (generation.current !== requestId) return; | 468 | if (Exit.isSuccess(exit)) { |
| 246 | // Reachability is best-effort — the presets and URL detection above | 469 | searchCache.set(cacheKey(request), { at: Date.now(), entries: exit.value.entries }); |
| 247 | // the catalog section keep working without it. | 470 | } |
| 248 | setState({ entries: Exit.isSuccess(exit) ? exit.value : [], loading: false }); | 471 | if (generation.current !== requestId) return; |
| 249 | }); | 472 | setState({ |
| 250 | }, SEARCH_DEBOUNCE_MS); | 473 | entries: Exit.isSuccess(exit) ? exit.value.entries : [], |
| 474 | forKey: cacheKey(request), | ||
| 475 | loading: false, | ||
| 476 | failed: Exit.isFailure(exit), | ||
| 477 | }); | ||
| 478 | }); | ||
| 479 | }, | ||
| 480 | // The first browse should not sit behind a debounce meant for typing. | ||
| 481 | query.length === 0 ? 0 : SEARCH_DEBOUNCE_MS, | ||
| 482 | ); | ||
| 251 | return () => clearTimeout(timer); | 483 | return () => clearTimeout(timer); |
| 252 | }, [query]); | 484 | }, [query, kind, limit]); |
| 485 | |||
| 486 | const extras = more.key === requestKey ? more : null; | ||
| 487 | const baseCount = state.entries.length; | ||
| 488 | const hasMore = | ||
| 489 | !state.loading && | ||
| 490 | !state.failed && | ||
| 491 | baseCount >= limit && | ||
| 492 | !(extras?.exhausted ?? false) && | ||
| 493 | !(query.length > 0 && query.length < MIN_QUERY_LENGTH); | ||
| 494 | |||
| 495 | // SYNCHRONOUS in-flight guard. A fast scroll fires the sentinel several | ||
| 496 | // times before React commits `loadingMore`, and every call then saw the | ||
| 497 | // stale false, fetched the SAME offset in parallel, appended the duplicate | ||
| 498 | // page, and pushed the next offset past rows that were never shown. A ref | ||
| 499 | // is settled the instant the first call claims it. | ||
| 500 | const pageInFlight = useRef(false); | ||
| 501 | |||
| 502 | const loadMore = useCallback(() => { | ||
| 503 | if (!hasMore || pageInFlight.current) return; | ||
| 504 | pageInFlight.current = true; | ||
| 505 | setMore((previous) => | ||
| 506 | previous.key === requestKey | ||
| 507 | ? { ...previous, loadingMore: true } | ||
| 508 | : { key: requestKey, entries: [], rawConsumed: 0, loadingMore: true, exhausted: false }, | ||
| 509 | ); | ||
| 510 | // The first page consumed `limit` raw rows (were it short, hasMore would | ||
| 511 | // already be false); extra pages report their raw size. | ||
| 512 | const request: CatalogQuery = { | ||
| 513 | query, | ||
| 514 | limit, | ||
| 515 | offset: limit + (extras?.rawConsumed ?? 0), | ||
| 516 | ...(kind ? { kind } : {}), | ||
| 517 | }; | ||
| 518 | void Effect.runPromiseExit(searchCatalog(request)).then((exit) => { | ||
| 519 | pageInFlight.current = false; | ||
| 520 | setMore((previous) => { | ||
| 521 | if (previous.key !== requestKey) return previous; | ||
| 522 | const page = Exit.isSuccess(exit) ? exit.value : { entries: [], rawCount: 0 }; | ||
| 523 | return { | ||
| 524 | key: requestKey, | ||
| 525 | entries: [...previous.entries, ...page.entries], | ||
| 526 | rawConsumed: previous.rawConsumed + page.rawCount, | ||
| 527 | loadingMore: false, | ||
| 528 | // Exhaustion keys on the RAW page size: the connectable-kind filter | ||
| 529 | // shrinks pages, and a failed fetch is not the end of the catalog — | ||
| 530 | // the sentinel simply tries again next time it scrolls into view. | ||
| 531 | exhausted: Exit.isSuccess(exit) && page.rawCount < limit, | ||
| 532 | }; | ||
| 533 | }); | ||
| 534 | }); | ||
| 535 | }, [hasMore, requestKey, query, kind, limit, extras?.rawConsumed]); | ||
| 536 | |||
| 537 | const entries = useMemo(() => { | ||
| 538 | if (!extras || extras.entries.length === 0) return state.entries; | ||
| 539 | // Ranking can shift between page fetches (the live index moves); a row | ||
| 540 | // that slid across a page boundary must not render twice. | ||
| 541 | const seen = new Set(state.entries.map((entry) => `${entry.domain}|${entry.name ?? ""}`)); | ||
| 542 | return [ | ||
| 543 | ...state.entries, | ||
| 544 | ...extras.entries.filter((entry) => { | ||
| 545 | const key = `${entry.domain}|${entry.name ?? ""}`; | ||
| 546 | if (seen.has(key)) return false; | ||
| 547 | seen.add(key); | ||
| 548 | return true; | ||
| 549 | }), | ||
| 550 | ]; | ||
| 551 | }, [state.entries, extras]); | ||
| 552 | |||
| 553 | return { | ||
| 554 | entries, | ||
| 555 | loading: state.loading, | ||
| 556 | failed: state.failed, | ||
| 557 | /** The rendered entries belong to a PREVIOUS query (held over while the | ||
| 558 | * current one loads, or while the input is one character). Callers | ||
| 559 | * decide the treatment; unmarked stale results read as wrong results. */ | ||
| 560 | stale: state.forKey !== requestKey, | ||
| 561 | hasMore: hasMore && state.forKey === requestKey, | ||
| 562 | loadingMore: extras?.loadingMore ?? false, | ||
| 563 | loadMore, | ||
| 564 | }; | ||
| 565 | } | ||
| 566 | |||
| 567 | // --------------------------------------------------------------------------- | ||
| 568 | // Credential guidance | ||
| 569 | // | ||
| 570 | // The per-domain surface document records, for each way of authenticating, | ||
| 571 | // what the credential is called at the provider, the page that mints it, and | ||
| 572 | // the provider's own setup steps. The console fetched this document for its | ||
| 573 | // connect URL and discarded the rest — which is why the add-connection modal | ||
| 574 | // could not answer "what kind of key do I need?" or "where do I get one?" | ||
| 575 | // while asking for exactly that. | ||
| 576 | // --------------------------------------------------------------------------- | ||
| 577 | |||
| 578 | export interface CredentialGuidance { | ||
| 579 | readonly id: string; | ||
| 580 | readonly type: string; | ||
| 581 | /** What the provider calls this credential ("Personal API key"). */ | ||
| 582 | readonly label: string; | ||
| 583 | /** The page that mints it. */ | ||
| 584 | readonly generateUrl?: string; | ||
| 585 | /** The provider's own instructions, as markdown. */ | ||
| 586 | readonly setup?: string; | ||
| 587 | } | ||
| 588 | |||
| 589 | const CredentialsDocument = Schema.Struct({ | ||
| 590 | credentials: Schema.optional( | ||
| 591 | Schema.Record( | ||
| 592 | Schema.String, | ||
| 593 | Schema.Struct({ | ||
| 594 | type: Schema.optional(Schema.String), | ||
| 595 | label: Schema.optional(Schema.String), | ||
| 596 | generateUrl: Schema.optional(Schema.String), | ||
| 597 | setup: Schema.optional(Schema.String), | ||
| 598 | }), | ||
| 599 | ), | ||
| 600 | ), | ||
| 601 | }); | ||
| 602 | const decodeCredentials = Schema.decodeUnknownOption(CredentialsDocument); | ||
| 603 | |||
| 604 | export const fetchCredentialGuidance = ( | ||
| 605 | domain: string, | ||
| 606 | ): Effect.Effect<readonly CredentialGuidance[], CatalogRequestError> => { | ||
| 607 | const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN); | ||
| 608 | return Effect.map(fetchCatalogJson(url), (payload) => | ||
| 609 | Option.match(decodeCredentials(payload), { | ||
| 610 | onNone: () => [], | ||
| 611 | onSome: ({ credentials }) => | ||
| 612 | Object.entries(credentials ?? {}).map(([id, value]) => ({ | ||
| 613 | id, | ||
| 614 | type: value.type ?? "unknown", | ||
| 615 | label: value.label ?? id, | ||
| 616 | ...(value.generateUrl ? { generateUrl: value.generateUrl } : {}), | ||
| 617 | ...(value.setup ? { setup: value.setup } : {}), | ||
| 618 | })), | ||
| 619 | }), | ||
| 620 | ); | ||
| 621 | }; | ||
| 622 | |||
| 623 | const guidanceCache = new Map<string, readonly CredentialGuidance[]>(); | ||
| 624 | |||
| 625 | /** Guidance for a domain, or an empty list when the registry has none. Never | ||
| 626 | * fails loudly: this is help text, and a connection form must work without it. */ | ||
| 627 | export function useCredentialGuidance(domain: string | null): readonly CredentialGuidance[] { | ||
| 628 | const [state, setState] = useState<readonly CredentialGuidance[]>([]); | ||
| 629 | |||
| 630 | useEffect(() => { | ||
| 631 | if (!domain) { | ||
| 632 | setState([]); | ||
| 633 | return; | ||
| 634 | } | ||
| 635 | const cached = guidanceCache.get(domain); | ||
| 636 | if (cached) { | ||
| 637 | setState(cached); | ||
| 638 | return; | ||
| 639 | } | ||
| 640 | let live = true; | ||
| 641 | void Effect.runPromiseExit(fetchCredentialGuidance(domain)).then((exit) => { | ||
| 642 | const value = Exit.isSuccess(exit) ? exit.value : []; | ||
| 643 | guidanceCache.set(domain, value); | ||
| 644 | if (live) setState(value); | ||
| 645 | }); | ||
| 646 | return () => { | ||
| 647 | live = false; | ||
| 648 | }; | ||
| 649 | }, [domain]); | ||
| 253 | 650 | ||
| 254 | return state; | 651 | return state; |
| 255 | } | 652 | } |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Link, Outlet, useLocation, useParams } from "@tanstack/react-router"; | 1 | import { Link, Outlet, useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 2 | import { useEffect, useRef, useState, type ReactNode } from "react"; | 2 | import { useEffect, useRef, useState, type ReactNode } from "react"; |
| 3 | import { useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| @@ -23,7 +23,6 @@ import { · 18 unmodified lines | |||
| 23 | } from "../components/integration-favicon"; | 23 | } from "../components/integration-favicon"; |
| 24 | import { CommandPalette } from "../components/command-palette"; | 24 | import { CommandPalette } from "../components/command-palette"; |
| 25 | import { Wordmark } from "../components/wordmark"; | 25 | import { Wordmark } from "../components/wordmark"; |
| 26 | import { ConnectDialog } from "../pages/integrations"; | ||
| 27 | import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; | 26 | import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 28 | import { useAuth } from "./auth-context"; | 27 | import { useAuth } from "./auth-context"; |
| 29 | 28 | ||
| @@ -428,7 +427,13 @@ export function Shell(props: ShellProps) { · 398 unmodified lines | |||
| 428 | const lastPathname = useRef(pathname); | 427 | const lastPathname = useRef(pathname); |
| 429 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); | 428 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); |
| 430 | const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); | 429 | const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); |
| 431 | const [connectIntegrationOpen, setConnectIntegrationOpen] = useState(false); | 430 | const navigate = useNavigate(); |
| 431 | // The connect dialog became the full-page picker; the sidebar affordance | ||
| 432 | // navigates instead of opening a modal. | ||
| 433 | const openIntegrationBrowse = () => { | ||
| 434 | trackEvent("integration_browse_opened", { via: "sidebar" }); | ||
| 435 | void navigate({ to: "/{-$orgSlug}/integrations/browse" }); | ||
| 436 | }; | ||
| 432 | if (lastPathname.current !== pathname) { | 437 | if (lastPathname.current !== pathname) { |
| 433 | lastPathname.current = pathname; | 438 | lastPathname.current = pathname; |
| 434 | if (mobileSidebarOpen) setMobileSidebarOpen(false); | 439 | if (mobileSidebarOpen) setMobileSidebarOpen(false); |
| @@ -446,17 +451,13 @@ export function Shell(props: ShellProps) { · 11 unmodified lines | |||
| 446 | return ( | 451 | return ( |
| 447 | <div className="flex h-screen overflow-hidden"> | 452 | <div className="flex h-screen overflow-hidden"> |
| 448 | <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} /> | 453 | <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} /> |
| 449 | <ConnectDialog open={connectIntegrationOpen} onOpenChange={setConnectIntegrationOpen} /> | ||
| 450 | {/* Desktop sidebar */} | 454 | {/* Desktop sidebar */} |
| 451 | <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56"> | 455 | <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56"> |
| 452 | <SidebarContent | 456 | <SidebarContent |
| 453 | {...props} | 457 | {...props} |
| 454 | pathname={pathname} | 458 | pathname={pathname} |
| 455 | onOpenCommands={() => setCommandPaletteOpen(true)} | 459 | onOpenCommands={() => setCommandPaletteOpen(true)} |
| 456 | onOpenIntegrationConnect={() => { | 460 | onOpenIntegrationConnect={openIntegrationBrowse} |
| 457 | setConnectIntegrationOpen(true); | ||
| 458 | trackEvent("integration_connect_dialog_opened"); | ||
| 459 | }} | ||
| 460 | /> | 461 | /> |
| 461 | </aside> | 462 | </aside> |
| 462 | 463 | ||
| @@ -502,8 +503,7 @@ export function Shell(props: ShellProps) { · 39 unmodified lines | |||
| 502 | }} | 503 | }} |
| 503 | onOpenIntegrationConnect={() => { | 504 | onOpenIntegrationConnect={() => { |
| 504 | setMobileSidebarOpen(false); | 505 | setMobileSidebarOpen(false); |
| 505 | setConnectIntegrationOpen(true); | 506 | openIntegrationBrowse(); |
| 506 | trackEvent("integration_connect_dialog_opened"); | ||
| 507 | }} | 507 | }} |
| 508 | /> | 508 | /> |
| 509 | </div> | 509 | </div> |
| @@ -15,9 +15,14 @@ export function AddIntegrationPage(props: { · 14 unmodified lines | |||
| 15 | url?: string; | 15 | url?: string; |
| 16 | preset?: string; | 16 | preset?: string; |
| 17 | namespace?: string; | 17 | namespace?: string; |
| 18 | authHeader?: string; | ||
| 19 | authNote?: string; | ||
| 20 | authKind?: string; | ||
| 21 | specOverrides?: string; | ||
| 18 | }) { | 22 | }) { |
| 19 | useExecutorDocumentTitle("Add integration"); | 23 | useExecutorDocumentTitle("Add integration"); |
| 20 | const { pluginKey, url, preset, namespace } = props; | 24 | const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } = |
| 25 | props; | ||
| 21 | const navigate = useNavigate(); | 26 | const navigate = useNavigate(); |
| 22 | const integrationPlugins = useIntegrationPlugins(); | 27 | const integrationPlugins = useIntegrationPlugins(); |
| 23 | const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); | 28 | const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); |
| @@ -57,6 +62,10 @@ export function AddIntegrationPage(props: { · 33 unmodified lines | |||
| 57 | initialUrl={url} | 62 | initialUrl={url} |
| 58 | initialPreset={preset} | 63 | initialPreset={preset} |
| 59 | initialNamespace={namespace} | 64 | initialNamespace={namespace} |
| 65 | initialAuthHeader={authHeader} | ||
| 66 | initialAuthNote={authNote} | ||
| 67 | initialAuthKind={authKind} | ||
| 68 | initialSpecOverrides={specOverrides} | ||
| 60 | onComplete={(slug?: string) => { | 69 | onComplete={(slug?: string) => { |
| 61 | trackEvent("integration_added", { | 70 | trackEvent("integration_added", { |
| 62 | plugin_key: pluginKey, | 71 | plugin_key: pluginKey, |
| @@ -0,0 +1,1041 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | ||
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | ||
| 3 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | ||
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Exit from "effect/Exit"; | ||
| 7 | import { PlusIcon, SearchIcon } from "lucide-react"; | ||
| 8 | import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared"; | ||
| 9 | import { | ||
| 10 | useIntegrationPlugins, | ||
| 11 | type IntegrationPlugin, | ||
| 12 | type IntegrationPreset, | ||
| 13 | type IntegrationQuickAddInput, | ||
| 14 | type IntegrationQuickAddResult, | ||
| 15 | } from "@executor-js/sdk/client"; | ||
| 16 | |||
| 17 | import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms"; | ||
| 18 | import { slugifyNamespace } from "../plugins/namespace"; | ||
| 19 | import { trackEvent } from "../api/analytics"; | ||
| 20 | import { Button } from "../components/button"; | ||
| 21 | import { Input } from "../components/input"; | ||
| 22 | import { PageHeader } from "../components/page"; | ||
| 23 | import { | ||
| 24 | integrationFaviconUrl, | ||
| 25 | integrationInferredUrl, | ||
| 26 | integrationPresetIconUrl, | ||
| 27 | } from "../components/integration-favicon"; | ||
| 28 | import { Skeleton } from "../components/skeleton"; | ||
| 29 | import { useExecutorDocumentTitle } from "../lib/document-title"; | ||
| 30 | import { | ||
| 31 | availableCatalogKinds, | ||
| 32 | catalogLogoUrl, | ||
| 33 | filterCatalogEntries, | ||
| 34 | resolveConnectTarget, | ||
| 35 | useCatalogBrowse, | ||
| 36 | type CatalogKind, | ||
| 37 | type CatalogSearchEntry, | ||
| 38 | type CatalogSurface, | ||
| 39 | } from "../lib/integrations-sh-catalog"; | ||
| 40 | |||
| 41 | // --------------------------------------------------------------------------- | ||
| 42 | // The full-page integration picker. | ||
| 43 | // | ||
| 44 | // Replaces the connect dialog. Finding something to connect is the first task | ||
| 45 | // of onboarding, so it gets a page and a permanent, focused search field rather | ||
| 46 | // than a 16rem scroll window behind a header button. | ||
| 47 | // | ||
| 48 | // ONE ROW PER ADDABLE THING. A service exposing both an API and an MCP server | ||
| 49 | // yields two rows, because they really are two different integrations with | ||
| 50 | // different tools and different auth — but each row NAMES its surface | ||
| 51 | // ("Stripe API", "Stripe MCP") rather than repeating the bare service name and | ||
| 52 | // leaving a badge to carry the difference. Two rows reading "Stripe" look like | ||
| 53 | // a duplicate; two rows reading "Stripe API" and "Stripe MCP" look like a | ||
| 54 | // choice, which is what they are. | ||
| 55 | // | ||
| 56 | // THE REGISTRY IS THE LIST. Every connectable card is a registry row: the | ||
| 57 | // registry carries the name, domain, description, spec or endpoint, auth | ||
| 58 | // facts, and corrective spec overrides, and the spec itself declares how to | ||
| 59 | // authenticate — a deployment's first-party OAuth clients bind at connect | ||
| 60 | // time by endpoint host, not through the picker. The one exception is | ||
| 61 | // presets WITHOUT a connect URL (local-process servers like Chrome DevTools | ||
| 62 | // over stdio): those cannot be registry rows yet, so they remain cards. | ||
| 63 | // | ||
| 64 | // FACETS ARE SURFACE KIND, NOT CATEGORY. There is no taxonomy to facet on, so a | ||
| 65 | // category rail would have to invent one. Kind is real, filtered server-side. | ||
| 66 | // --------------------------------------------------------------------------- | ||
| 67 | |||
| 68 | const KIND_TO_PLUGIN_KEY: Record<string, string> = { | ||
| 69 | openapi: "openapi", | ||
| 70 | mcp: "mcp", | ||
| 71 | graphql: "graphql", | ||
| 72 | googleDiscovery: "google", | ||
| 73 | }; | ||
| 74 | |||
| 75 | const CATALOG_KIND_LABEL: Record<CatalogKind, string> = { | ||
| 76 | mcp: "MCP", | ||
| 77 | openapi: "OpenAPI", | ||
| 78 | graphql: "GraphQL", | ||
| 79 | }; | ||
| 80 | |||
| 81 | /** How a surface is said in a row's NAME. Deliberately not the facet | ||
| 82 | * vocabulary: a facet filters on the spec format ("OpenAPI"), while a name | ||
| 83 | * says the thing you would say out loud ("Stripe API"). */ | ||
| 84 | const SURFACE_WORD: Record<string, string> = { | ||
| 85 | mcp: "MCP", | ||
| 86 | openapi: "API", | ||
| 87 | google: "API", | ||
| 88 | graphql: "GraphQL", | ||
| 89 | }; | ||
| 90 | |||
| 91 | /** `Stripe` + API → `Stripe API`, but `GitHub REST` + API stays `GitHub REST` | ||
| 92 | * and `Emulate MCP` + MCP stays `Emulate MCP`. Appending a word the name | ||
| 93 | * already carries reads worse than leaving it off. */ | ||
| 94 | const withSurface = (name: string, surface: string): string => { | ||
| 95 | const lower = name.toLowerCase(); | ||
| 96 | const already = | ||
| 97 | lower.includes(surface.toLowerCase()) || | ||
| 98 | (surface === "API" && (lower.includes("api") || lower.includes("rest"))); | ||
| 99 | return already ? name : `${name} ${surface}`; | ||
| 100 | }; | ||
| 101 | |||
| 102 | /** The key on which a preset and a catalog row count as the same product: | ||
| 103 | * the name with surface words and punctuation stripped, plus the kind. | ||
| 104 | * "GitHub REST" and "GitHub API" are one product; "Stripe API" and | ||
| 105 | * "Stripe MCP" are not. */ | ||
| 106 | const productKey = (name: string, kind: string): string => | ||
| 107 | `${name | ||
| 108 | .toLowerCase() | ||
| 109 | .replace(/\b(api|rest|graphql|mcp)\b/g, "") | ||
| 110 | .replace(/[^a-z0-9]/g, "")}|${kind}`; | ||
| 111 | |||
| 112 | const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = { | ||
| 113 | high: 3, | ||
| 114 | medium: 2, | ||
| 115 | low: 1, | ||
| 116 | }; | ||
| 117 | |||
| 118 | const bestDetection = ( | ||
| 119 | results: readonly IntegrationDetectionResult[], | ||
| 120 | ): IntegrationDetectionResult | undefined => | ||
| 121 | [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; | ||
| 122 | |||
| 123 | /** The input either names a thing to look for or points at one. Anything with | ||
| 124 | * a scheme, a slash, or a host-with-TLD is a URL; everything else is a query. */ | ||
| 125 | const looksLikeUrl = (raw: string): boolean => { | ||
| 126 | const value = raw.trim(); | ||
| 127 | if (value.length === 0) return false; | ||
| 128 | if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(value)) return true; | ||
| 129 | if (value.includes("/")) return true; | ||
| 130 | if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(value)) return true; | ||
| 131 | return false; | ||
| 132 | }; | ||
| 133 | |||
| 134 | /** A readable product name for a bare domain: `gmail.googleapis.com` reads as | ||
| 135 | * "Gmail", `linear.app` as "Linear". The full domain still renders beside it, | ||
| 136 | * because two services can share a leading label and the domain is what | ||
| 137 | * actually disambiguates them. */ | ||
| 138 | const domainDisplayName = (domain: string): string => { | ||
| 139 | const host = domain.replace(/^www\./, ""); | ||
| 140 | const label = host.split(".")[0] ?? host; | ||
| 141 | return label.charAt(0).toUpperCase() + label.slice(1); | ||
| 142 | }; | ||
| 143 | |||
| 144 | /** The registry truncates descriptions to a fixed width, which lands mid-word | ||
| 145 | * ("…read, manage, and send m"). Trim back to the last whole word so the cut | ||
| 146 | * reads as deliberate. */ | ||
| 147 | const tidyDescription = (text: string): string => { | ||
| 148 | const trimmed = text.trim(); | ||
| 149 | if (trimmed.length === 0) return trimmed; | ||
| 150 | if (/[.!?]$/.test(trimmed)) return trimmed; | ||
| 151 | const lastSpace = trimmed.lastIndexOf(" "); | ||
| 152 | return `${(lastSpace > 40 ? trimmed.slice(0, lastSpace) : trimmed).replace(/[,;:]$/, "")}…`; | ||
| 153 | }; | ||
| 154 | |||
| 155 | type PresetEntry = { | ||
| 156 | readonly preset: IntegrationPreset; | ||
| 157 | readonly pluginKey: string; | ||
| 158 | readonly pluginLabel: string; | ||
| 159 | }; | ||
| 160 | |||
| 161 | interface Row { | ||
| 162 | readonly key: string; | ||
| 163 | readonly testId: string; | ||
| 164 | readonly title: string; | ||
| 165 | /** Surface kind for product matching (productKey); not rendered. */ | ||
| 166 | readonly kindKey: string; | ||
| 167 | /** The domain, shown beside a prettified title so near-namesakes stay | ||
| 168 | * distinguishable. Omitted when the title is already the domain. */ | ||
| 169 | readonly domain?: string; | ||
| 170 | readonly description?: string; | ||
| 171 | readonly iconUrl?: string; | ||
| 172 | readonly onSelect: () => void; | ||
| 173 | readonly added: boolean; | ||
| 174 | /** Namespace of the installed integration this row matched — the View | ||
| 175 | * destination. Known for quick-added rows and for rows whose installed | ||
| 176 | * match came from the catalog list. */ | ||
| 177 | readonly viewSlug?: string; | ||
| 178 | /** Added DURING this picker session (quick add). Excluded from the | ||
| 179 | * added-first float: the card the user just clicked must keep its place — | ||
| 180 | * teleporting it to the top mid-interaction is exactly the jumping this | ||
| 181 | * page keeps having to unlearn. It floats on the next visit. */ | ||
| 182 | readonly freshlyAdded?: boolean; | ||
| 183 | readonly busy: boolean; | ||
| 184 | /** A click-time failure, rendered on this card rather than page-top. */ | ||
| 185 | readonly error?: string; | ||
| 186 | } | ||
| 187 | |||
| 188 | // --------------------------------------------------------------------------- | ||
| 189 | // Infinite scroll | ||
| 190 | // --------------------------------------------------------------------------- | ||
| 191 | |||
| 192 | /** Fires `onVisible` whenever the sentinel scrolls near the viewport. The | ||
| 193 | * rootMargin starts the next page a screen early, so scrolling never visibly | ||
| 194 | * hits the bottom of a still-growing list. */ | ||
| 195 | function LoadMoreSentinel(props: { readonly onVisible: () => void }) { | ||
| 196 | const ref = useRef<HTMLDivElement | null>(null); | ||
| 197 | const { onVisible } = props; | ||
| 198 | useEffect(() => { | ||
| 199 | const node = ref.current; | ||
| 200 | if (!node) return; | ||
| 201 | const observer = new IntersectionObserver( | ||
| 202 | (observed) => { | ||
| 203 | if (observed.some((entry) => entry.isIntersecting)) onVisible(); | ||
| 204 | }, | ||
| 205 | { rootMargin: "600px" }, | ||
| 206 | ); | ||
| 207 | observer.observe(node); | ||
| 208 | return () => observer.disconnect(); | ||
| 209 | }, [onVisible]); | ||
| 210 | return <div ref={ref} aria-hidden className="h-px" />; | ||
| 211 | } | ||
| 212 | |||
| 213 | // --------------------------------------------------------------------------- | ||
| 214 | // Row | ||
| 215 | // --------------------------------------------------------------------------- | ||
| 216 | |||
| 217 | /** Registry logos 404 for plenty of hosts; a broken-image glyph in a list of | ||
| 218 | * brand marks looks like a bug, so failures fall back to a neutral mark. */ | ||
| 219 | function RowIcon(props: { readonly src?: string; readonly alt: string }) { | ||
| 220 | const [failed, setFailed] = useState(false); | ||
| 221 | if (!props.src || failed) { | ||
| 222 | return ( | ||
| 223 | <span | ||
| 224 | aria-hidden | ||
| 225 | className="flex size-5 items-center justify-center rounded-sm bg-muted text-[10px] font-medium text-muted-foreground" | ||
| 226 | > | ||
| 227 | {props.alt.charAt(0).toUpperCase()} | ||
| 228 | </span> | ||
| 229 | ); | ||
| 230 | } | ||
| 231 | return ( | ||
| 232 | <img | ||
| 233 | src={props.src} | ||
| 234 | alt="" | ||
| 235 | width={20} | ||
| 236 | height={20} | ||
| 237 | // NOT lazy. The console scrolls inside a nested container, and Chrome's | ||
| 238 | // lazy loading never brings these into view there — every icon sits | ||
| 239 | // pending forever while an eager load of the same URL succeeds instantly. | ||
| 240 | // A 20px favicon is not worth deferring anyway. | ||
| 241 | onError={() => setFailed(true)} | ||
| 242 | className="size-5 object-contain" | ||
| 243 | /> | ||
| 244 | ); | ||
| 245 | } | ||
| 246 | |||
| 247 | function ResultCard(props: { readonly row: Row }) { | ||
| 248 | const { row } = props; | ||
| 249 | return ( | ||
| 250 | <div | ||
| 251 | data-testid={row.testId} | ||
| 252 | className="flex min-h-[8.5rem] flex-col gap-2.5 rounded-lg border border-border/60 p-4 transition-colors hover:border-border hover:bg-accent/20" | ||
| 253 | > | ||
| 254 | <div className="flex items-start justify-between gap-2"> | ||
| 255 | <span className="flex size-8 shrink-0 items-center justify-center"> | ||
| 256 | <RowIcon {...(row.iconUrl ? { src: row.iconUrl } : {})} alt={row.title} /> | ||
| 257 | </span> | ||
| 258 | {row.added ? ( | ||
| 259 | // The add happened HERE — the card is the receipt. View jumps to | ||
| 260 | // the integration's hub, where authenticating happens. | ||
| 261 | row.viewSlug ? ( | ||
| 262 | <Button asChild variant="outline" size="sm" aria-label={`View ${row.title}`}> | ||
| 263 | <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: row.viewSlug }}> | ||
| 264 | View | ||
| 265 | </Link> | ||
| 266 | </Button> | ||
| 267 | ) : ( | ||
| 268 | <span className="px-2 py-1.5 text-xs font-medium text-muted-foreground">Added</span> | ||
| 269 | ) | ||
| 270 | ) : row.busy ? ( | ||
| 271 | <span className="px-2 py-1.5 text-xs text-muted-foreground">Adding…</span> | ||
| 272 | ) : ( | ||
| 273 | <Button | ||
| 274 | type="button" | ||
| 275 | variant="outline" | ||
| 276 | size="sm" | ||
| 277 | onClick={row.onSelect} | ||
| 278 | // Every card's button reads "Add", so the visible label alone is | ||
| 279 | // useless to a screen reader; the accessible name carries the card. | ||
| 280 | aria-label={`Add ${row.title}`} | ||
| 281 | > | ||
| 282 | Add | ||
| 283 | </Button> | ||
| 284 | )} | ||
| 285 | </div> | ||
| 286 | <div className="min-w-0"> | ||
| 287 | <p className="truncate text-sm font-medium text-foreground">{row.title}</p> | ||
| 288 | {row.domain ? ( | ||
| 289 | <p className="truncate font-mono text-[11px] text-muted-foreground/70">{row.domain}</p> | ||
| 290 | ) : null} | ||
| 291 | </div> | ||
| 292 | {row.description ? ( | ||
| 293 | <p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground"> | ||
| 294 | {row.description} | ||
| 295 | </p> | ||
| 296 | ) : null} | ||
| 297 | {row.error ? ( | ||
| 298 | <p role="alert" className="text-xs text-destructive"> | ||
| 299 | {row.error} | ||
| 300 | </p> | ||
| 301 | ) : null} | ||
| 302 | </div> | ||
| 303 | ); | ||
| 304 | } | ||
| 305 | |||
| 306 | function CardSkeleton(props: { readonly index: number }) { | ||
| 307 | const { index } = props; | ||
| 308 | return ( | ||
| 309 | <div className="flex flex-col gap-2.5 rounded-lg border border-border/60 p-4"> | ||
| 310 | <div className="flex items-start justify-between gap-2"> | ||
| 311 | <Skeleton className="size-8 rounded-md" /> | ||
| 312 | <Skeleton className="h-8 w-16 rounded-md" /> | ||
| 313 | </div> | ||
| 314 | <div className="space-y-1.5"> | ||
| 315 | <Skeleton className="h-3.5" style={{ width: `${40 + ((index * 11) % 30)}%` }} /> | ||
| 316 | <Skeleton className="h-3" style={{ width: `${55 + ((index * 13) % 30)}%` }} /> | ||
| 317 | </div> | ||
| 318 | <Skeleton className="h-3" style={{ width: `${70 + ((index * 7) % 25)}%` }} /> | ||
| 319 | </div> | ||
| 320 | ); | ||
| 321 | } | ||
| 322 | |||
| 323 | // --------------------------------------------------------------------------- | ||
| 324 | // Quick add | ||
| 325 | // --------------------------------------------------------------------------- | ||
| 326 | |||
| 327 | type QuickAddFn = (input: IntegrationQuickAddInput) => Promise<IntegrationQuickAddResult>; | ||
| 328 | |||
| 329 | /** Collects each plugin's bound quick-add callback into the page's map. | ||
| 330 | * A component per plugin, not a hook over a list: `useQuickAdd` is a hook, | ||
| 331 | * and mounting one bridge per plugin is how a fixed set of per-plugin hooks | ||
| 332 | * stays rules-of-hooks clean. */ | ||
| 333 | function QuickAddBridge(props: { | ||
| 334 | readonly pluginKey: string; | ||
| 335 | /** The plugin's hook, called UNCONDITIONALLY — the host renders a bridge | ||
| 336 | * only for plugins that have one, so this component's hook sequence never | ||
| 337 | * depends on a plugin object's shape. */ | ||
| 338 | readonly useQuickAdd: () => QuickAddFn; | ||
| 339 | readonly register: (key: string, fn: QuickAddFn | null) => void; | ||
| 340 | }) { | ||
| 341 | const { pluginKey, useQuickAdd, register } = props; | ||
| 342 | const fn = useQuickAdd(); | ||
| 343 | useEffect(() => { | ||
| 344 | register(pluginKey, fn); | ||
| 345 | return () => register(pluginKey, null); | ||
| 346 | }, [pluginKey, fn, register]); | ||
| 347 | return null; | ||
| 348 | } | ||
| 349 | |||
| 350 | /** One bridge per plugin KEY: a duplicate key would share the React key and | ||
| 351 | * the callback slot, letting either copy's cleanup delete the other. */ | ||
| 352 | function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { | ||
| 353 | const seen = new Set<string>(); | ||
| 354 | return plugins.filter((plugin) => { | ||
| 355 | if (!plugin.useQuickAdd || seen.has(plugin.key)) return false; | ||
| 356 | seen.add(plugin.key); | ||
| 357 | return true; | ||
| 358 | }); | ||
| 359 | } | ||
| 360 | |||
| 361 | // --------------------------------------------------------------------------- | ||
| 362 | // Page | ||
| 363 | // --------------------------------------------------------------------------- | ||
| 364 | |||
| 365 | export function IntegrationBrowsePage() { | ||
| 366 | useExecutorDocumentTitle("Add an integration"); | ||
| 367 | const navigate = useNavigate(); | ||
| 368 | const integrationPlugins = useIntegrationPlugins(); | ||
| 369 | const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); | ||
| 370 | const installed = useAtomValue(integrationsOptimisticAtom); | ||
| 371 | |||
| 372 | const [query, setQuery] = useState(""); | ||
| 373 | const [detecting, setDetecting] = useState(false); | ||
| 374 | const [error, setError] = useState<string | null>(null); | ||
| 375 | // Resolve-on-click failures belong on the card that was clicked — a page-top | ||
| 376 | // alert for a card three screens down reads as a silent failure. | ||
| 377 | const [rowError, setRowError] = useState<{ | ||
| 378 | readonly key: string; | ||
| 379 | readonly message: string; | ||
| 380 | } | null>(null); | ||
| 381 | const [resolvingDomain, setResolvingDomain] = useState<string | null>(null); | ||
| 382 | // One-click adds run per-card and CONCURRENTLY — adding Linear must not | ||
| 383 | // lock the Notion card. Keyed by row error-key (`domain|kind`). | ||
| 384 | const [quickAddingKeys, setQuickAddingKeys] = useState<ReadonlySet<string>>(new Set()); | ||
| 385 | // Slugs of integrations added from this page, keyed the same way, so the | ||
| 386 | // card can flip to View without waiting for the catalog refetch. | ||
| 387 | const [quickAddedSlugs, setQuickAddedSlugs] = useState<ReadonlyMap<string, string>>(new Map()); | ||
| 388 | const quickAdders = useRef(new Map<string, QuickAddFn>()); | ||
| 389 | const registerQuickAdd = useCallback((key: string, fn: QuickAddFn | null) => { | ||
| 390 | if (fn) quickAdders.current.set(key, fn); | ||
| 391 | else quickAdders.current.delete(key); | ||
| 392 | }, []); | ||
| 393 | |||
| 394 | const isUrl = looksLikeUrl(query); | ||
| 395 | // A URL is a destination, not a filter — but half-typed URLs pass through | ||
| 396 | // here on every keystroke ("stripe.co" parses as one), and swapping the | ||
| 397 | // grid back to the browse head mid-typing made the whole page jump. Freeze | ||
| 398 | // the list on the last real query instead; the detect hint is what changes. | ||
| 399 | const lastTextQuery = useRef(""); | ||
| 400 | if (!isUrl) lastTextQuery.current = query; | ||
| 401 | const listQuery = isUrl ? lastTextQuery.current : query; | ||
| 402 | const text = listQuery.trim().toLowerCase(); | ||
| 403 | |||
| 404 | const availableKinds = useMemo( | ||
| 405 | () => availableCatalogKinds(integrationPlugins), | ||
| 406 | [integrationPlugins], | ||
| 407 | ); | ||
| 408 | // Nothing to exclude now that the registry is the only source. | ||
| 409 | const excludeDomains = useMemo(() => new Set<string>(), []); | ||
| 410 | |||
| 411 | const catalog = useCatalogBrowse({ query: listQuery }); | ||
| 412 | const catalogEntries = useMemo(() => { | ||
| 413 | const usable = filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds }); | ||
| 414 | if (!catalog.stale) return usable; | ||
| 415 | // Held-over results from the previous query: keep only what still reads | ||
| 416 | // as an answer to the live text. Every whitespace token must appear in | ||
| 417 | // the entry's name or domain — refining "google"→"google cal" keeps | ||
| 418 | // cloud.google.com rows (the match lives in the domain), while a new | ||
| 419 | // word drops the old rows instead of parading them under the wrong | ||
| 420 | // query, which is how a calendar search showed Gmail. An empty text | ||
| 421 | // (clearing back to browse) holds nothing. | ||
| 422 | const tokens = listQuery.trim().toLowerCase().split(/\s+/).filter(Boolean); | ||
| 423 | if (tokens.length === 0) return []; | ||
| 424 | return usable.filter((entry) => { | ||
| 425 | const haystack = | ||
| 426 | `${entry.name ?? domainDisplayName(entry.domain)} ${entry.domain}`.toLowerCase(); | ||
| 427 | return tokens.every((token) => haystack.includes(token)); | ||
| 428 | }); | ||
| 429 | }, [catalog.entries, catalog.stale, listQuery, excludeDomains, availableKinds]); | ||
| 430 | |||
| 431 | /** What is already connected, as `<slug>:<kind>`. | ||
| 432 | * | ||
| 433 | * The slug is the closest thing to an identity available today — it is the | ||
| 434 | * namespace the add flow seeds from a registry surface's `slug` or a | ||
| 435 | * preset's name. Kind is carried alongside it because slugs are NOT unique | ||
| 436 | * per surface: the OpenAPI and MCP presets for Stripe, Neon, Sentry and | ||
| 437 | * Axiom share a name and an id, so both seed the same namespace. Without the | ||
| 438 | * kind, adding one marks the other added — the same false positive the | ||
| 439 | * earlier domain match produced. | ||
| 440 | * | ||
| 441 | * This still under-reports: a connection the user renamed on the way in no | ||
| 442 | * longer matches, and reads as not-added. That is deliberate — offering to | ||
| 443 | * add something twice is recoverable, claiming something is connected when | ||
| 444 | * it is not is not. The real fix is recording at add time which row an | ||
| 445 | * integration came from; nothing here can infer it after the fact. */ | ||
| 446 | const installedKeys = useMemo(() => { | ||
| 447 | const rows: readonly Integration[] = AsyncResult.isSuccess(installed) ? installed.value : []; | ||
| 448 | return new Set( | ||
| 449 | rows.map((row) => `${String(row.slug)}:${KIND_TO_PLUGIN_KEY[row.kind] ?? row.kind}`), | ||
| 450 | ); | ||
| 451 | }, [installed]); | ||
| 452 | |||
| 453 | const isAdded = useCallback( | ||
| 454 | (kind: string, ...candidates: readonly (string | undefined)[]): boolean => | ||
| 455 | candidates.some((candidate) => { | ||
| 456 | if (!candidate) return false; | ||
| 457 | return ( | ||
| 458 | installedKeys.has(`${candidate}:${kind}`) || | ||
| 459 | installedKeys.has(`${slugifyNamespace(candidate)}:${kind}`) | ||
| 460 | ); | ||
| 461 | }), | ||
| 462 | [installedKeys], | ||
| 463 | ); | ||
| 464 | |||
| 465 | /** The installed integration's own namespace for an added row — the View | ||
| 466 | * button's destination. Same matching rules as `isAdded`. */ | ||
| 467 | const installedSlugFor = useCallback( | ||
| 468 | (kind: string, ...candidates: readonly (string | undefined)[]): string | undefined => { | ||
| 469 | for (const candidate of candidates) { | ||
| 470 | if (!candidate) continue; | ||
| 471 | if (installedKeys.has(`${candidate}:${kind}`)) return candidate; | ||
| 472 | const slugified = slugifyNamespace(candidate); | ||
| 473 | if (installedKeys.has(`${slugified}:${kind}`)) return slugified; | ||
| 474 | } | ||
| 475 | return undefined; | ||
| 476 | }, | ||
| 477 | [installedKeys], | ||
| 478 | ); | ||
| 479 | |||
| 480 | const allPresets = useMemo(() => { | ||
| 481 | const entries: PresetEntry[] = []; | ||
| 482 | for (const plugin of integrationPlugins) { | ||
| 483 | for (const preset of plugin.presets ?? []) { | ||
| 484 | entries.push({ preset, pluginKey: plugin.key, pluginLabel: plugin.label }); | ||
| 485 | } | ||
| 486 | } | ||
| 487 | return entries; | ||
| 488 | }, [integrationPlugins]); | ||
| 489 | |||
| 490 | const handleDetect = useCallback(async () => { | ||
| 491 | const trimmed = query.trim(); | ||
| 492 | if (trimmed.length === 0) return; | ||
| 493 | setDetecting(true); | ||
| 494 | setError(null); | ||
| 495 | // Detection is read-only — it inspects a URL and returns candidates without | ||
| 496 | // mutating the catalog, so it invalidates nothing. | ||
| 497 | const exit = await doDetect({ payload: { url: trimmed }, reactivityKeys: [] }); | ||
| 498 | if (Exit.isFailure(exit)) { | ||
| 499 | trackEvent("integration_detect_submitted", { success: false }); | ||
| 500 | setError("Couldn't reach that URL. Check it, or start from scratch below."); | ||
| 501 | setDetecting(false); | ||
| 502 | return; | ||
| 503 | } | ||
| 504 | const detected = bestDetection(exit.value); | ||
| 505 | if (!detected) { | ||
| 506 | trackEvent("integration_detect_submitted", { success: false }); | ||
| 507 | setError("Couldn't tell what that URL exposes. Start from scratch below."); | ||
| 508 | setDetecting(false); | ||
| 509 | return; | ||
| 510 | } | ||
| 511 | trackEvent("integration_detect_submitted", { | ||
| 512 | success: true, | ||
| 513 | detected_kind: detected.kind, | ||
| 514 | confidence: detected.confidence, | ||
| 515 | }); | ||
| 516 | const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind] ?? detected.kind; | ||
| 517 | if (!integrationPlugins.some((plugin) => plugin.key === pluginKey)) { | ||
| 518 | setError(`That looks like a ${detected.kind} integration, which this server can't add.`); | ||
| 519 | setDetecting(false); | ||
| 520 | return; | ||
| 521 | } | ||
| 522 | trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" }); | ||
| 523 | void navigate({ | ||
| 524 | to: "/{-$orgSlug}/integrations/add/$pluginKey", | ||
| 525 | params: { pluginKey }, | ||
| 526 | search: { url: trimmed, namespace: detected.slug }, | ||
| 527 | }); | ||
| 528 | }, [query, doDetect, navigate, integrationPlugins]); | ||
| 529 | |||
| 530 | const goToAdd = useCallback( | ||
| 531 | (input: { | ||
| 532 | readonly kind: string; | ||
| 533 | readonly url: string; | ||
| 534 | readonly slug?: string; | ||
| 535 | readonly domain: string; | ||
| 536 | readonly auth?: CatalogSurface["auth"]; | ||
| 537 | readonly specOverrides?: CatalogSurface["specOverrides"]; | ||
| 538 | }) => { | ||
| 539 | trackEvent("integration_add_started", { | ||
| 540 | plugin_key: input.kind, | ||
| 541 | via: "catalog", | ||
| 542 | catalog_domain: input.domain, | ||
| 543 | }); | ||
| 544 | void navigate({ | ||
| 545 | to: "/{-$orgSlug}/integrations/add/$pluginKey", | ||
| 546 | params: { pluginKey: input.kind }, | ||
| 547 | search: { | ||
| 548 | url: input.url, | ||
| 549 | ...(input.slug ? { namespace: input.slug } : {}), | ||
| 550 | ...(input.auth?.header ? { authHeader: input.auth.header } : {}), | ||
| 551 | ...(input.auth?.note ? { authNote: input.auth.note } : {}), | ||
| 552 | ...(input.auth?.kind ? { authKind: input.auth.kind } : {}), | ||
| 553 | ...(input.specOverrides ? { specOverrides: JSON.stringify(input.specOverrides) } : {}), | ||
| 554 | }, | ||
| 555 | }); | ||
| 556 | }, | ||
| 557 | [navigate], | ||
| 558 | ); | ||
| 559 | |||
| 560 | /** One-click add, in place. The registry knew the URL and the auth | ||
| 561 | * indicators, so there is nothing to configure — register directly and | ||
| 562 | * flip the card to View, leaving the user on this page to keep adding. | ||
| 563 | * Returns false when it could not (no quick-add for the kind, probe or | ||
| 564 | * registration failed), and the caller falls back to the configuration | ||
| 565 | * screen, which renders the same failure with full context. */ | ||
| 566 | const tryQuickAdd = useCallback( | ||
| 567 | async (input: { | ||
| 568 | readonly kind: CatalogKind; | ||
| 569 | readonly url: string; | ||
| 570 | readonly title: string; | ||
| 571 | readonly domain: string; | ||
| 572 | /** The row's identity key — carries the PRODUCT, not just domain|kind: | ||
| 573 | * one domain can offer two products of the same kind (the Google | ||
| 574 | * Photos Library and Picker), and a domain-level key marked both cards | ||
| 575 | * added when either one was. */ | ||
| 576 | readonly rowKey: string; | ||
| 577 | readonly slug?: string; | ||
| 578 | readonly auth?: CatalogSurface["auth"]; | ||
| 579 | readonly specOverrides?: CatalogSurface["specOverrides"]; | ||
| 580 | }): Promise<boolean> => { | ||
| 581 | const fn = quickAdders.current.get(KIND_TO_PLUGIN_KEY[input.kind] ?? input.kind); | ||
| 582 | if (!fn) return false; | ||
| 583 | const rowKey = input.rowKey; | ||
| 584 | setQuickAddingKeys((previous) => new Set(previous).add(rowKey)); | ||
| 585 | trackEvent("integration_add_started", { | ||
| 586 | plugin_key: input.kind, | ||
| 587 | via: "catalog", | ||
| 588 | catalog_domain: input.domain, | ||
| 589 | }); | ||
| 590 | const result = await fn({ | ||
| 591 | url: input.url, | ||
| 592 | name: input.title, | ||
| 593 | domain: input.domain, | ||
| 594 | ...(input.slug ? { slug: input.slug } : {}), | ||
| 595 | ...(input.auth?.header ? { authHeader: input.auth.header } : {}), | ||
| 596 | ...(input.auth?.kind ? { authKind: input.auth.kind } : {}), | ||
| 597 | ...(input.specOverrides ? { specOverrides: input.specOverrides } : {}), | ||
| 598 | }); | ||
| 599 | setQuickAddingKeys((previous) => { | ||
| 600 | const next = new Set(previous); | ||
| 601 | next.delete(rowKey); | ||
| 602 | return next; | ||
| 603 | }); | ||
| 604 | if (!result.ok) return false; | ||
| 605 | setQuickAddedSlugs((previous) => new Map(previous).set(rowKey, result.slug)); | ||
| 606 | return true; | ||
| 607 | }, | ||
| 608 | [], | ||
| 609 | ); | ||
| 610 | |||
| 611 | const pickCatalogEntry = useCallback( | ||
| 612 | async ( | ||
| 613 | entry: CatalogSearchEntry, | ||
| 614 | kind: CatalogKind, | ||
| 615 | title: string, | ||
| 616 | rowKey: string, | ||
| 617 | // The EXACT surface this card was built from. Never re-derived from | ||
| 618 | // `kind`: one domain can carry two same-kind products (Google Photos | ||
| 619 | // Library and Picker), and a kind lookup silently merged the clicked | ||
| 620 | // card's URL with the FIRST surface's slug, auth, and overrides. | ||
| 621 | surface?: CatalogSurface, | ||
| 622 | ) => { | ||
| 623 | const knownUrl = surface?.url; | ||
| 624 | if (knownUrl) { | ||
| 625 | const added = await tryQuickAdd({ | ||
| 626 | kind, | ||
| 627 | url: knownUrl, | ||
| 628 | title, | ||
| 629 | domain: entry.domain, | ||
| 630 | rowKey, | ||
| 631 | ...(surface ? { slug: surface.slug } : {}), | ||
| 632 | ...(surface?.auth ? { auth: surface.auth } : {}), | ||
| 633 | ...(surface?.specOverrides ? { specOverrides: surface.specOverrides } : {}), | ||
| 634 | }); | ||
| 635 | if (added) return; | ||
| 636 | goToAdd({ | ||
| 637 | kind, | ||
| 638 | url: knownUrl, | ||
| 639 | domain: entry.domain, | ||
| 640 | ...(surface ? { slug: surface.slug } : {}), | ||
| 641 | ...(surface?.auth ? { auth: surface.auth } : {}), | ||
| 642 | ...(surface?.specOverrides ? { specOverrides: surface.specOverrides } : {}), | ||
| 643 | }); | ||
| 644 | return; | ||
| 645 | } | ||
| 646 | if (resolvingDomain !== null) return; | ||
| 647 | setResolvingDomain(entry.domain); | ||
| 648 | setRowError(null); | ||
| 649 | // The row's product name disambiguates same-kind surfaces in the doc | ||
| 650 | // (the registry row itself was kinds-only, so the name is all we have). | ||
| 651 | const exit = await Effect.runPromiseExit( | ||
| 652 | resolveConnectTarget(entry.domain, [kind], entry.name ?? undefined), | ||
| 653 | ); | ||
| 654 | setResolvingDomain(null); | ||
| 655 | if (Exit.isFailure(exit) || !exit.value) { | ||
| 656 | setRowError({ | ||
| 657 | key: rowKey, | ||
| 658 | message: "Couldn't load connect details. Paste its URL above instead.", | ||
| 659 | }); | ||
| 660 | return; | ||
| 661 | } | ||
| 662 | const target = exit.value; | ||
| 663 | const added = await tryQuickAdd({ | ||
| 664 | kind: target.kind, | ||
| 665 | url: target.url, | ||
| 666 | title, | ||
| 667 | domain: entry.domain, | ||
| 668 | rowKey, | ||
| 669 | ...(target.slug ? { slug: target.slug } : {}), | ||
| 670 | }); | ||
| 671 | if (added) return; | ||
| 672 | goToAdd({ | ||
| 673 | kind: target.kind, | ||
| 674 | url: target.url, | ||
| 675 | domain: entry.domain, | ||
| 676 | ...(target.slug ? { slug: target.slug } : {}), | ||
| 677 | }); | ||
| 678 | }, | ||
| 679 | [goToAdd, resolvingDomain, tryQuickAdd], | ||
| 680 | ); | ||
| 681 | |||
| 682 | const pickPreset = useCallback( | ||
| 683 | (entry: PresetEntry) => { | ||
| 684 | trackEvent("integration_add_started", { | ||
| 685 | plugin_key: entry.pluginKey, | ||
| 686 | via: "preset", | ||
| 687 | preset_id: entry.preset.id, | ||
| 688 | }); | ||
| 689 | void navigate({ | ||
| 690 | to: "/{-$orgSlug}/integrations/add/$pluginKey", | ||
| 691 | params: { pluginKey: entry.pluginKey }, | ||
| 692 | search: { preset: entry.preset.id }, | ||
| 693 | }); | ||
| 694 | }, | ||
| 695 | [navigate], | ||
| 696 | ); | ||
| 697 | |||
| 698 | // --- Preset rows --------------------------------------------------------- | ||
| 699 | // | ||
| 700 | // Only presets WITHOUT a connect URL remain cards: a preset with a URL is a | ||
| 701 | // registry row's job now, while a local-process server (Chrome DevTools over | ||
| 702 | // stdio) has no registry representation yet. | ||
| 703 | const presetRows = useMemo<readonly Row[]>(() => { | ||
| 704 | const rows: Row[] = []; | ||
| 705 | for (const entry of allPresets) { | ||
| 706 | // A URL preset the registry lists is the registry row's job. A custom | ||
| 707 | // deployment preset has no registry row — hiding it would make a | ||
| 708 | // private API undiscoverable, so it keeps its card. | ||
| 709 | if ( | ||
| 710 | (entry.preset.url !== undefined || entry.preset.endpoint !== undefined) && | ||
| 711 | entry.preset.registryListed === true | ||
| 712 | ) | ||
| 713 | continue; | ||
| 714 | if (text.length > 0) { | ||
| 715 | const corpus = | ||
| 716 | `${entry.preset.name} ${entry.preset.summary ?? ""} ${entry.preset.family ?? ""} ${entry.pluginLabel}`.toLowerCase(); | ||
| 717 | if (!corpus.includes(text)) continue; | ||
| 718 | } | ||
| 719 | const surface = SURFACE_WORD[entry.pluginKey] ?? entry.pluginLabel; | ||
| 720 | const title = withSurface(entry.preset.name, surface); | ||
| 721 | const kindKey = entry.pluginKey === "google" ? "openapi" : entry.pluginKey; | ||
| 722 | rows.push({ | ||
| 723 | key: `preset-${entry.pluginKey}-${entry.preset.id}`, | ||
| 724 | testId: `preset-${entry.pluginKey}-${entry.preset.id}`, | ||
| 725 | title, | ||
| 726 | kindKey, | ||
| 727 | ...(entry.preset.summary ? { description: entry.preset.summary } : {}), | ||
| 728 | ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}), | ||
| 729 | onSelect: () => pickPreset(entry), | ||
| 730 | // The rendered title is also what the add flow derives the namespace | ||
| 731 | // from, so the card recognises an add made through it even when the | ||
| 732 | // preset declares no defaultSlug and its bare name would miss. | ||
| 733 | added: isAdded(entry.pluginKey, entry.preset.defaultSlug, entry.preset.name, title), | ||
| 734 | busy: false, | ||
| 735 | }); | ||
| 736 | } | ||
| 737 | return rows; | ||
| 738 | }, [allPresets, text, pickPreset, isAdded]); | ||
| 739 | |||
| 740 | // --- Catalog rows: one per (service, surface) ----------------------------- | ||
| 741 | const catalogRows = useMemo<readonly Row[]>(() => { | ||
| 742 | let rows = catalogEntries.flatMap((entry): readonly Row[] => { | ||
| 743 | const pretty = entry.name ?? domainDisplayName(entry.domain); | ||
| 744 | const description = entry.description ? tidyDescription(entry.description) : undefined; | ||
| 745 | // Prefer the registry's own per-surface records, and within them only | ||
| 746 | // the surfaces that carry a connect target: the registry can know a | ||
| 747 | // surface EXISTS without knowing where it lives (conjur.org's OpenAPI | ||
| 748 | // has no recorded spec URL), and a card whose Add can only fail is | ||
| 749 | // worse than no card. Kinds-only entries (older registries, stubs) keep | ||
| 750 | // the resolve-on-click fallback. | ||
| 751 | const surfaces: readonly (CatalogSurface | { readonly kind: CatalogKind })[] = | ||
| 752 | entry.surfaces && entry.surfaces.length > 0 | ||
| 753 | ? entry.surfaces.filter((surface) => surface.url) | ||
| 754 | : entry.kinds.map((kind) => ({ kind })); | ||
| 755 | return surfaces.map((surface): Row => { | ||
| 756 | const known = "slug" in surface ? surface : null; | ||
| 757 | const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind]; | ||
| 758 | const title = withSurface(pretty, word); | ||
| 759 | // Product identity, not just domain|kind: two same-kind products can | ||
| 760 | // share a domain (Google Photos Library and Picker). | ||
| 761 | const rowKey = `${entry.domain}|${surface.kind}|${known?.slug ?? pretty}`; | ||
| 762 | const quickAddedSlug = quickAddedSlugs.get(rowKey); | ||
| 763 | // Same candidate set as `added`: namespaces come from the registry | ||
| 764 | // slug (quick add), the domain, or the display name (classic add | ||
| 765 | // pages), depending on how the integration got here. | ||
| 766 | const viewSlug = | ||
| 767 | quickAddedSlug ?? installedSlugFor(surface.kind, known?.slug, entry.domain, title); | ||
| 768 | // Slug in the key: one domain can carry many product surfaces of the | ||
| 769 | // same kind (Microsoft Graph's workloads all live on one domain). | ||
| 770 | return { | ||
| 771 | key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`, | ||
| 772 | testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`, | ||
| 773 | title, | ||
| 774 | kindKey: surface.kind, | ||
| 775 | domain: entry.domain, | ||
| 776 | ...(description ? { description } : {}), | ||
| 777 | iconUrl: | ||
| 778 | (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10), | ||
| 779 | onSelect: () => | ||
| 780 | void pickCatalogEntry(entry, surface.kind, title, rowKey, known ?? undefined), | ||
| 781 | added: | ||
| 782 | quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug, entry.domain, title), | ||
| 783 | ...(quickAddedSlug !== undefined ? { freshlyAdded: true } : {}), | ||
| 784 | ...(viewSlug ? { viewSlug } : {}), | ||
| 785 | busy: resolvingDomain === entry.domain || quickAddingKeys.has(rowKey), | ||
| 786 | ...(rowError?.key === rowKey ? { error: rowError.message } : {}), | ||
| 787 | }; | ||
| 788 | }); | ||
| 789 | }); | ||
| 790 | // A registry row for something a preset already offers is a worse copy of | ||
| 791 | // it: same service, no auth template, no health check. Matched on | ||
| 792 | // productKey — normalized name plus kind — because neither titles nor | ||
| 793 | // domains line up reliably: the Gmail preset's icon is Google's (so a | ||
| 794 | // domain comparison let a second "Gmail API" through), and the GitHub | ||
| 795 | // preset says "GitHub REST" where the registry row says "GitHub API". | ||
| 796 | const presetKeys = new Set(presetRows.map((row) => productKey(row.title, row.kindKey))); | ||
| 797 | rows = rows.filter((row) => !presetKeys.has(productKey(row.title, row.kindKey))); | ||
| 798 | if (text.length === 0) return rows; | ||
| 799 | // A name match beats a mention in the blurb: searching "gmail" should not | ||
| 800 | // rank a CRM that merely describes itself as living inside Gmail above | ||
| 801 | // Gmail. Stable within each group, so the registry's own order survives. | ||
| 802 | const isNamed = (row: Row) => `${row.title} ${row.domain ?? ""}`.toLowerCase().includes(text); | ||
| 803 | return [...rows.filter(isNamed), ...rows.filter((row) => !isNamed(row))]; | ||
| 804 | }, [ | ||
| 805 | catalogEntries, | ||
| 806 | isAdded, | ||
| 807 | installedSlugFor, | ||
| 808 | quickAddedSlugs, | ||
| 809 | quickAddingKeys, | ||
| 810 | resolvingDomain, | ||
| 811 | pickCatalogEntry, | ||
| 812 | text, | ||
| 813 | presetRows, | ||
| 814 | rowError, | ||
| 815 | ]); | ||
| 816 | |||
| 817 | // Local-process cards hold no rank of their own, so each slots in where its | ||
| 818 | // title falls alphabetically among the surrounding rows — pinning them to | ||
| 819 | // the top of a popularity-ranked list gave Chrome DevTools pride of place | ||
| 820 | // over everything. They also WAIT for the catalog: painting a lone Chrome | ||
| 821 | // DevTools card while the registry loads reads as a one-item catalog, then | ||
| 822 | // reflows. When the registry is unreachable they render anyway, so the page | ||
| 823 | // degrades to the cards that still work. | ||
| 824 | const results = useMemo(() => { | ||
| 825 | if (catalog.loading && catalogRows.length === 0 && !catalog.failed) return catalogRows; | ||
| 826 | const merged = [...catalogRows]; | ||
| 827 | for (const preset of presetRows) { | ||
| 828 | const at = merged.findIndex( | ||
| 829 | (row) => row.title.localeCompare(preset.title, undefined, { sensitivity: "base" }) > 0, | ||
| 830 | ); | ||
| 831 | merged.splice(at === -1 ? merged.length : at, 0, preset); | ||
| 832 | } | ||
| 833 | // What you already have leads the list — those cards are the ones with a | ||
| 834 | // state worth seeing (View). Stable within each half, and fresh | ||
| 835 | // quick-adds hold their place until the next visit. | ||
| 836 | const floats = (row: Row) => row.added && row.freshlyAdded !== true; | ||
| 837 | // Installed integrations whose registry rows are NOT in the loaded page | ||
| 838 | // still belong at the front — "added first" is about the user's catalog, | ||
| 839 | // not about which sixty rows the registry ranked highest. Synthesized | ||
| 840 | // from the integration record itself; a loaded row that matched an | ||
| 841 | // installed integration claims its slug, so nothing appears twice. | ||
| 842 | const claimed = new Set(merged.flatMap((row) => (row.viewSlug ? [row.viewSlug] : []))); | ||
| 843 | const installedList: readonly Integration[] = AsyncResult.isSuccess(installed) | ||
| 844 | ? installed.value | ||
| 845 | : []; | ||
| 846 | const installedRows: readonly Row[] = installedList | ||
| 847 | .filter( | ||
| 848 | (row) => | ||
| 849 | row.canRemove && | ||
| 850 | (row.kind === "mcp" || row.kind === "openapi" || row.kind === "graphql") && | ||
| 851 | !claimed.has(String(row.slug)) && | ||
| 852 | (text.length === 0 || | ||
| 853 | row.name.toLowerCase().includes(text) || | ||
| 854 | String(row.slug).includes(text)), | ||
| 855 | ) | ||
| 856 | .map((row): Row => { | ||
| 857 | // The SAME icon cascade the sidebar runs — preset icon by exact | ||
| 858 | // identity, else the logo proxy from the integration's own URL. Two | ||
| 859 | // resolvers for one integration is how the sidebar and the picker | ||
| 860 | // ended up disagreeing about what DeepWiki looks like. | ||
| 861 | const iconUrl = | ||
| 862 | integrationPresetIconUrl( | ||
| 863 | { | ||
| 864 | id: String(row.slug), | ||
| 865 | kind: row.kind, | ||
| 866 | name: row.name, | ||
| 867 | ...(row.displayUrl ? { url: row.displayUrl } : {}), | ||
| 868 | }, | ||
| 869 | integrationPlugins, | ||
| 870 | ) ?? | ||
| 871 | integrationFaviconUrl( | ||
| 872 | row.displayUrl ?? | ||
| 873 | integrationInferredUrl({ id: String(row.slug), name: row.name }) ?? | ||
| 874 | undefined, | ||
| 875 | 10, | ||
| 876 | ); | ||
| 877 | return { | ||
| 878 | key: `installed-${String(row.slug)}`, | ||
| 879 | testId: `installed-${String(row.slug)}`, | ||
| 880 | title: row.name, | ||
| 881 | kindKey: row.kind, | ||
| 882 | ...(row.description && row.description !== row.name | ||
| 883 | ? { description: tidyDescription(row.description) } | ||
| 884 | : {}), | ||
| 885 | ...(iconUrl ? { iconUrl } : {}), | ||
| 886 | onSelect: () => {}, | ||
| 887 | added: true, | ||
| 888 | viewSlug: String(row.slug), | ||
| 889 | busy: false, | ||
| 890 | }; | ||
| 891 | }); | ||
| 892 | return [...installedRows, ...merged.filter(floats), ...merged.filter((row) => !floats(row))]; | ||
| 893 | }, [ | ||
| 894 | presetRows, | ||
| 895 | catalogRows, | ||
| 896 | catalog.loading, | ||
| 897 | catalog.failed, | ||
| 898 | installed, | ||
| 899 | text, | ||
| 900 | integrationPlugins, | ||
| 901 | ]); | ||
| 902 | |||
| 903 | // Presets are local, so a list that already has them is not empty — show | ||
| 904 | // skeletons only when there is genuinely nothing on screen yet. | ||
| 905 | const loading = catalog.loading && results.length === 0; | ||
| 906 | |||
| 907 | return ( | ||
| 908 | // PageContainer scrolls the whole page; this page pins the header and | ||
| 909 | // search, and ONLY the results grid scrolls — an endless list makes the | ||
| 910 | // page scrollbar meaningless and drags the search box off screen. | ||
| 911 | <div className="flex min-h-0 flex-1 flex-col" data-slot="page-container"> | ||
| 912 | {quickAddCapablePlugins(integrationPlugins).map((plugin) => ( | ||
| 913 | <QuickAddBridge | ||
| 914 | key={plugin.key} | ||
| 915 | pluginKey={plugin.key} | ||
| 916 | useQuickAdd={plugin.useQuickAdd!} | ||
| 917 | register={registerQuickAdd} | ||
| 918 | /> | ||
| 919 | ))} | ||
| 920 | <div className="mx-auto w-full max-w-4xl shrink-0 px-6 pt-10 lg:px-8 lg:pt-14"> | ||
| 921 | <PageHeader | ||
| 922 | title="Add an integration" | ||
| 923 | description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint." | ||
| 924 | /> | ||
| 925 | |||
| 926 | <div className="mb-4 flex gap-2"> | ||
| 927 | <div className="relative min-w-0 flex-1"> | ||
| 928 | <SearchIcon | ||
| 929 | className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" | ||
| 930 | aria-hidden | ||
| 931 | /> | ||
| 932 | <Input | ||
| 933 | type="text" | ||
| 934 | value={query} | ||
| 935 | onChange={(event) => { | ||
| 936 | setQuery((event.target as HTMLInputElement).value); | ||
| 937 | setError(null); | ||
| 938 | }} | ||
| 939 | onKeyDown={(event) => { | ||
| 940 | if (event.key === "Enter" && isUrl) void handleDetect(); | ||
| 941 | }} | ||
| 942 | placeholder="Search integrations, or paste a URL…" | ||
| 943 | aria-label="Search integrations, or paste a URL" | ||
| 944 | disabled={detecting} | ||
| 945 | // oxlint-disable-next-line jsx_a11y/no-autofocus -- deliberate: searching is the page's only purpose, and it is reached by an explicit "Add integration" action | ||
| 946 | autoFocus | ||
| 947 | className="h-11 pl-9 text-sm" | ||
| 948 | /> | ||
| 949 | </div> | ||
| 950 | {isUrl ? ( | ||
| 951 | <Button | ||
| 952 | className="h-11 shrink-0" | ||
| 953 | onClick={() => void handleDetect()} | ||
| 954 | disabled={detecting || query.trim().length === 0} | ||
| 955 | loading={detecting} | ||
| 956 | > | ||
| 957 | Add this URL | ||
| 958 | </Button> | ||
| 959 | ) : null} | ||
| 960 | </div> | ||
| 961 | |||
| 962 | {/* Above the results, not below: an endless list has no reachable | ||
| 963 | bottom, and this is the escape hatch for exactly the person the | ||
| 964 | list is failing. One quiet line; the label carries the action, so | ||
| 965 | the chips stay bare format names. */} | ||
| 966 | <div className="mb-8 flex flex-wrap items-center gap-x-3 gap-y-2"> | ||
| 967 | <span className="shrink-0 text-xs text-muted-foreground">Start from scratch:</span> | ||
| 968 | <div className="flex shrink-0 items-center gap-1.5"> | ||
| 969 | {integrationPlugins.map( | ||
| 970 | (plugin: IntegrationPlugin): ReactNode => ( | ||
| 971 | // oxlint-disable-next-line react/forbid-elements -- a chip, not a Button variant | ||
| 972 | <button | ||
| 973 | key={plugin.key} | ||
| 974 | type="button" | ||
| 975 | aria-label={`New ${plugin.label} integration from scratch`} | ||
| 976 | onClick={() => { | ||
| 977 | trackEvent("integration_add_started", { | ||
| 978 | plugin_key: plugin.key, | ||
| 979 | via: "manual", | ||
| 980 | }); | ||
| 981 | void navigate({ | ||
| 982 | to: "/{-$orgSlug}/integrations/add/$pluginKey", | ||
| 983 | params: { pluginKey: plugin.key }, | ||
| 984 | }); | ||
| 985 | }} | ||
| 986 | className="inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" | ||
| 987 | > | ||
| 988 | <PlusIcon className="size-3" aria-hidden /> | ||
| 989 | {plugin.label} | ||
| 990 | </button> | ||
| 991 | ), | ||
| 992 | )} | ||
| 993 | </div> | ||
| 994 | <span className="ml-auto shrink-0 text-xs text-muted-foreground"> | ||
| 995 | Can't find it? Paste its URL above. | ||
| 996 | </span> | ||
| 997 | </div> | ||
| 998 | |||
| 999 | {error ? ( | ||
| 1000 | <p role="alert" className="mb-4 text-xs text-destructive"> | ||
| 1001 | {error} | ||
| 1002 | </p> | ||
| 1003 | ) : null} | ||
| 1004 | </div> | ||
| 1005 | |||
| 1006 | <div className="min-h-0 flex-1 overflow-y-auto"> | ||
| 1007 | <div className="mx-auto w-full max-w-4xl px-6 pb-10 lg:px-8"> | ||
| 1008 | {loading ? ( | ||
| 1009 | <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> | ||
| 1010 | {Array.from({ length: 9 }).map((_, index) => ( | ||
| 1011 | <CardSkeleton key={index} index={index} /> | ||
| 1012 | ))} | ||
| 1013 | </div> | ||
| 1014 | ) : results.length === 0 ? ( | ||
| 1015 | <p className="px-3 py-10 text-center text-sm text-muted-foreground"> | ||
| 1016 | {catalog.failed | ||
| 1017 | ? "Couldn't load the full list right now. You can still paste the URL of an MCP server, OpenAPI spec, or GraphQL endpoint to add it directly." | ||
| 1018 | : "Nothing matches that. Paste the URL of an MCP server, OpenAPI spec, or GraphQL endpoint to add it directly."} | ||
| 1019 | </p> | ||
| 1020 | ) : ( | ||
| 1021 | <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> | ||
| 1022 | {results.map((row) => ( | ||
| 1023 | <ResultCard key={row.key} row={row} /> | ||
| 1024 | ))} | ||
| 1025 | {catalog.loadingMore | ||
| 1026 | ? Array.from({ length: 3 }, (_, index) => ( | ||
| 1027 | <CardSkeleton key={`more-${index}`} index={index} /> | ||
| 1028 | )) | ||
| 1029 | : null} | ||
| 1030 | {catalog.hasMore ? ( | ||
| 1031 | <div className="col-span-full"> | ||
| 1032 | <LoadMoreSentinel onVisible={catalog.loadMore} /> | ||
| 1033 | </div> | ||
| 1034 | ) : null} | ||
| 1035 | </div> | ||
| 1036 | )} | ||
| 1037 | </div> | ||
| 1038 | </div> | ||
| 1039 | </div> | ||
| 1040 | ); | ||
| 1041 | } | ||
| @@ -1,30 +1,15 @@ | |||
| 1 | import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react"; | 1 | import { Suspense, useMemo, type ReactNode } from "react"; |
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | 2 | import { Link } from "@tanstack/react-router"; |
| 3 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Exit from "effect/Exit"; | ||
| 7 | import { PlusIcon } from "lucide-react"; | 5 | import { PlusIcon } from "lucide-react"; |
| 8 | import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared"; | 6 | import type { Integration } from "@executor-js/sdk/shared"; |
| 9 | import { | 7 | import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/client"; |
| 10 | useIntegrationPlugins, | 8 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 11 | type IntegrationPlugin, | ||
| 12 | type IntegrationPreset, | ||
| 13 | } from "@executor-js/sdk/client"; | ||
| 14 | import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms"; | ||
| 15 | import { trackEvent } from "../api/analytics"; | 9 | import { trackEvent } from "../api/analytics"; |
| 16 | import { McpInstallCard } from "../components/mcp-install-card"; | 10 | import { McpInstallCard } from "../components/mcp-install-card"; |
| 17 | import { Button } from "../components/button"; | 11 | import { Button } from "../components/button"; |
| 18 | import { PageContainer, PageHeader } from "../components/page"; | 12 | import { PageContainer, PageHeader } from "../components/page"; |
| 19 | import { Badge } from "../components/badge"; | ||
| 20 | import { Input } from "../components/input"; | ||
| 21 | import { | ||
| 22 | Dialog, | ||
| 23 | DialogContent, | ||
| 24 | DialogDescription, | ||
| 25 | DialogHeader, | ||
| 26 | DialogTitle, | ||
| 27 | } from "../components/dialog"; | ||
| 28 | import { | 13 | import { |
| 29 | CardStack, | 14 | CardStack, |
| 30 | CardStackContent, | 15 | CardStackContent, |
| @@ -32,7 +17,6 @@ import { · 1 unmodified line | |||
| 32 | CardStackEntryActions, | 17 | CardStackEntryActions, |
| 33 | CardStackEntryContent, | 18 | CardStackEntryContent, |
| 34 | CardStackEntryDescription, | 19 | CardStackEntryDescription, |
| 35 | CardStackEntryMedia, | ||
| 36 | CardStackEntryTitle, | 20 | CardStackEntryTitle, |
| 37 | CardStackHeader, | 21 | CardStackHeader, |
| 38 | } from "../components/card-stack"; | 22 | } from "../components/card-stack"; |
| @@ -41,18 +25,7 @@ import { · 2 unmodified lines | |||
| 41 | integrationInferredUrl, | 25 | integrationInferredUrl, |
| 42 | integrationPresetIconUrl, | 26 | integrationPresetIconUrl, |
| 43 | } from "../components/integration-favicon"; | 27 | } from "../components/integration-favicon"; |
| 44 | import { PresetIcon } from "../components/preset-icon"; | ||
| 45 | import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping"; | 28 | import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping"; |
| 46 | import { | ||
| 47 | availableCatalogKinds, | ||
| 48 | catalogLogoUrl, | ||
| 49 | filterCatalogEntries, | ||
| 50 | presetDomains, | ||
| 51 | resolveConnectTarget, | ||
| 52 | useCatalogSearch, | ||
| 53 | type CatalogKind, | ||
| 54 | type CatalogSearchEntry, | ||
| 55 | } from "../lib/integrations-sh-catalog"; | ||
| 56 | import { IntegrationHealthSummary } from "../components/integration-health-summary"; | 29 | import { IntegrationHealthSummary } from "../components/integration-health-summary"; |
| 57 | import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; | 30 | import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; |
| 58 | import { Skeleton } from "../components/skeleton"; | 31 | import { Skeleton } from "../components/skeleton"; |
| @@ -67,17 +40,6 @@ const KIND_TO_PLUGIN_KEY: Record<string, string> = { · 8 unmodified lines | |||
| 67 | googleDiscovery: "google", | 40 | googleDiscovery: "google", |
| 68 | }; | 41 | }; |
| 69 | 42 | ||
| 70 | const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = { | ||
| 71 | high: 3, | ||
| 72 | medium: 2, | ||
| 73 | low: 1, | ||
| 74 | }; | ||
| 75 | |||
| 76 | const bestDetection = ( | ||
| 77 | results: readonly IntegrationDetectionResult[], | ||
| 78 | ): IntegrationDetectionResult | undefined => | ||
| 79 | [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; | ||
| 80 | |||
| 81 | // --------------------------------------------------------------------------- | 43 | // --------------------------------------------------------------------------- |
| 82 | // Page | 44 | // Page |
| 83 | // --------------------------------------------------------------------------- | 45 | // --------------------------------------------------------------------------- |
| @@ -86,7 +48,6 @@ export function IntegrationsPage() { · 2 unmodified lines | |||
| 86 | useExecutorDocumentTitle("Integrations"); | 48 | useExecutorDocumentTitle("Integrations"); |
| 87 | const integrations = useAtomValue(integrationsOptimisticAtom); | 49 | const integrations = useAtomValue(integrationsOptimisticAtom); |
| 88 | const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); | 50 | const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); |
| 89 | const [connectOpen, setConnectOpen] = useState(false); | ||
| 90 | 51 | ||
| 91 | return ( | 52 | return ( |
| 92 | <PageContainer> | 53 | <PageContainer> |
| @@ -94,16 +55,14 @@ export function IntegrationsPage() { · 1 unmodified line | |||
| 94 | title="Integrations" | 55 | title="Integrations" |
| 95 | description="Tool providers available in this workspace." | 56 | description="Tool providers available in this workspace." |
| 96 | actions={ | 57 | actions={ |
| 97 | <Button | 58 | <Button asChild size="sm" className="gap-1.5"> |
| 98 | onClick={() => { | 59 | <Link |
| 99 | setConnectOpen(true); | 60 | to="/{-$orgSlug}/integrations/browse" |
| 100 | trackEvent("integration_connect_dialog_opened"); | 61 | onClick={() => trackEvent("integration_browse_opened", { via: "header" })} |
| 101 | }} | 62 | > |
| 102 | size="sm" | 63 | <PlusIcon className="size-4" /> |
| 103 | className="gap-1.5" | 64 | Add integration |
| 104 | > | 65 | </Link> |
| 105 | <PlusIcon className="size-4" /> | ||
| 106 | Connect | ||
| 107 | </Button> | 66 | </Button> |
| 108 | } | 67 | } |
| 109 | /> | 68 | /> |
| @@ -124,14 +83,7 @@ export function IntegrationsPage() { · 14 unmodified lines | |||
| 124 | ), | 83 | ), |
| 125 | onSuccess: ({ value }) => { | 84 | onSuccess: ({ value }) => { |
| 126 | if (value.length === 0) { | 85 | if (value.length === 0) { |
| 127 | return ( | 86 | return <EmptyIntegrations />; |
| 128 | <EmptyIntegrations | ||
| 129 | onConnect={() => { | ||
| 130 | setConnectOpen(true); | ||
| 131 | trackEvent("integration_connect_dialog_opened"); | ||
| 132 | }} | ||
| 133 | /> | ||
| 134 | ); | ||
| 135 | } | 87 | } |
| 136 | 88 | ||
| 137 | return ( | 89 | return ( |
| @@ -142,176 +94,15 @@ export function IntegrationsPage() { · 4 unmodified lines | |||
| 142 | }, | 94 | }, |
| 143 | }) | 95 | }) |
| 144 | )} | 96 | )} |
| 145 | |||
| 146 | <ConnectDialog open={connectOpen} onOpenChange={setConnectOpen} /> | ||
| 147 | </PageContainer> | 97 | </PageContainer> |
| 148 | ); | 98 | ); |
| 149 | } | 99 | } |
| 150 | 100 | ||
| 151 | // --------------------------------------------------------------------------- | ||
| 152 | // Connect dialog — URL detection + manual plugin chooser + presets | ||
| 153 | // --------------------------------------------------------------------------- | ||
| 154 | |||
| 155 | // Heuristic: the input either looks like a URL (auto-detect) or a free-text | ||
| 156 | // search query (filter the preset list). Anything with a scheme, slash, or | ||
| 157 | // host-with-TLD is treated as a URL; everything else is search. | ||
| 158 | const looksLikeUrl = (raw: string): boolean => { | ||
| 159 | const v = raw.trim(); | ||
| 160 | if (v.length === 0) return false; | ||
| 161 | if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true; | ||
| 162 | if (v.includes("/")) return true; | ||
| 163 | if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true; | ||
| 164 | return false; | ||
| 165 | }; | ||
| 166 | |||
| 167 | export function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) { | ||
| 168 | const integrationPlugins = useIntegrationPlugins(); | ||
| 169 | const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); | ||
| 170 | const navigate = useNavigate(); | ||
| 171 | |||
| 172 | const [query, setQuery] = useState(""); | ||
| 173 | const [detecting, setDetecting] = useState(false); | ||
| 174 | const [error, setError] = useState<string | null>(null); | ||
| 175 | |||
| 176 | const isUrl = looksLikeUrl(query); | ||
| 177 | const presetSearch = isUrl ? "" : query; | ||
| 178 | |||
| 179 | const closeAndReset = useCallback(() => { | ||
| 180 | setQuery(""); | ||
| 181 | setError(null); | ||
| 182 | setDetecting(false); | ||
| 183 | props.onOpenChange(false); | ||
| 184 | }, [props]); | ||
| 185 | |||
| 186 | const handleDetect = useCallback(async () => { | ||
| 187 | const trimmed = query.trim(); | ||
| 188 | if (!trimmed) return; | ||
| 189 | setDetecting(true); | ||
| 190 | setError(null); | ||
| 191 | // Detection is read-only — it inspects a URL and returns candidates without | ||
| 192 | // mutating the catalog, so it invalidates nothing. | ||
| 193 | const exit = await doDetect({ | ||
| 194 | payload: { url: trimmed }, | ||
| 195 | reactivityKeys: [], | ||
| 196 | }); | ||
| 197 | if (Exit.isFailure(exit)) { | ||
| 198 | trackEvent("integration_detect_submitted", { success: false }); | ||
| 199 | setError("Detection failed. Try adding an integration manually."); | ||
| 200 | setDetecting(false); | ||
| 201 | return; | ||
| 202 | } | ||
| 203 | const results = exit.value; | ||
| 204 | if (results.length === 0) { | ||
| 205 | trackEvent("integration_detect_submitted", { success: false }); | ||
| 206 | setError("Could not detect an integration type from this URL. Try adding manually."); | ||
| 207 | setDetecting(false); | ||
| 208 | return; | ||
| 209 | } | ||
| 210 | const detected = bestDetection(results); | ||
| 211 | if (!detected) { | ||
| 212 | trackEvent("integration_detect_submitted", { success: false }); | ||
| 213 | setError("Could not detect an integration type from this URL. Try adding manually."); | ||
| 214 | setDetecting(false); | ||
| 215 | return; | ||
| 216 | } | ||
| 217 | trackEvent("integration_detect_submitted", { | ||
| 218 | success: true, | ||
| 219 | detected_kind: detected.kind, | ||
| 220 | confidence: detected.confidence, | ||
| 221 | }); | ||
| 222 | const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind] ?? detected.kind; | ||
| 223 | if (integrationPlugins.some((p) => p.key === pluginKey)) { | ||
| 224 | trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" }); | ||
| 225 | closeAndReset(); | ||
| 226 | void navigate({ | ||
| 227 | to: "/{-$orgSlug}/integrations/add/$pluginKey", | ||
| 228 | params: { pluginKey }, | ||
| 229 | search: { url: trimmed, namespace: detected.slug }, | ||
| 230 | }); | ||
| 231 | } else { | ||
| 232 | setError(`Detected integration type "${detected.kind}" but no plugin is available for it.`); | ||
| 233 | setDetecting(false); | ||
| 234 | } | ||
| 235 | }, [query, doDetect, navigate, integrationPlugins, closeAndReset]); | ||
| 236 | |||
| 237 | return ( | ||
| 238 | <Dialog | ||
| 239 | open={props.open} | ||
| 240 | onOpenChange={(open) => { | ||
| 241 | if (!open) closeAndReset(); | ||
| 242 | else props.onOpenChange(open); | ||
| 243 | }} | ||
| 244 | > | ||
| 245 | <DialogContent className="sm:max-w-[560px]"> | ||
| 246 | <DialogHeader> | ||
| 247 | <DialogTitle>Connect an integration</DialogTitle> | ||
| 248 | <DialogDescription> | ||
| 249 | Search the preset library, or paste a URL to auto-detect. | ||
| 250 | </DialogDescription> | ||
| 251 | </DialogHeader> | ||
| 252 | |||
| 253 | <div className="flex min-w-0 flex-col gap-5"> | ||
| 254 | <div className="flex flex-col gap-2"> | ||
| 255 | <div className="flex gap-2"> | ||
| 256 | <Input | ||
| 257 | type="text" | ||
| 258 | value={query} | ||
| 259 | onChange={(e) => { | ||
| 260 | setQuery((e.target as HTMLInputElement).value); | ||
| 261 | setError(null); | ||
| 262 | }} | ||
| 263 | onKeyDown={(e) => { | ||
| 264 | if (e.key === "Enter" && isUrl) void handleDetect(); | ||
| 265 | }} | ||
| 266 | placeholder="Search or paste a URL…" | ||
| 267 | disabled={detecting} | ||
| 268 | className="flex-1" | ||
| 269 | /> | ||
| 270 | {isUrl && ( | ||
| 271 | <Button onClick={() => void handleDetect()} disabled={detecting || !query.trim()}> | ||
| 272 | {detecting ? "Detecting..." : "Detect"} | ||
| 273 | </Button> | ||
| 274 | )} | ||
| 275 | </div> | ||
| 276 | {error && <p className="text-xs text-destructive">{error}</p>} | ||
| 277 | </div> | ||
| 278 | |||
| 279 | <div className="flex flex-col gap-2"> | ||
| 280 | <p className="text-xs font-medium text-foreground/80">Or add manually</p> | ||
| 281 | <div className="flex flex-wrap gap-2"> | ||
| 282 | {integrationPlugins.map((p) => ( | ||
| 283 | <Link | ||
| 284 | key={p.key} | ||
| 285 | to="/{-$orgSlug}/integrations/add/$pluginKey" | ||
| 286 | params={{ pluginKey: p.key }} | ||
| 287 | onClick={() => { | ||
| 288 | trackEvent("integration_add_started", { plugin_key: p.key, via: "manual" }); | ||
| 289 | closeAndReset(); | ||
| 290 | }} | ||
| 291 | className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted" | ||
| 292 | > | ||
| 293 | {p.label} | ||
| 294 | </Link> | ||
| 295 | ))} | ||
| 296 | </div> | ||
| 297 | </div> | ||
| 298 | |||
| 299 | <PresetGrid | ||
| 300 | plugins={integrationPlugins} | ||
| 301 | onPick={closeAndReset} | ||
| 302 | searchQuery={presetSearch} | ||
| 303 | /> | ||
| 304 | </div> | ||
| 305 | </DialogContent> | ||
| 306 | </Dialog> | ||
| 307 | ); | ||
| 308 | } | ||
| 309 | |||
| 310 | // --------------------------------------------------------------------------- | 101 | // --------------------------------------------------------------------------- |
| 311 | // Empty state | 102 | // Empty state |
| 312 | // --------------------------------------------------------------------------- | 103 | // --------------------------------------------------------------------------- |
| 313 | 104 | ||
| 314 | function EmptyIntegrations(props: { onConnect: () => void }) { | 105 | function EmptyIntegrations() { |
| 315 | return ( | 106 | return ( |
| 316 | <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16"> | 107 | <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16"> |
| 317 | <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground"> | 108 | <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground"> |
| @@ -321,230 +112,19 @@ function EmptyIntegrations(props: { onConnect: () => void }) { · 3 unmodified lines | |||
| 321 | <p className="mb-5 text-[13px] text-muted-foreground/60"> | 112 | <p className="mb-5 text-[13px] text-muted-foreground/60"> |
| 322 | Connect an integration to start curating tools. | 113 | Connect an integration to start curating tools. |
| 323 | </p> | 114 | </p> |
| 324 | <Button onClick={props.onConnect} size="sm" className="gap-1.5"> | 115 | <Button asChild size="sm" className="gap-1.5"> |
| 325 | <PlusIcon className="size-4" /> | 116 | <Link |
| 326 | Connect an integration | 117 | to="/{-$orgSlug}/integrations/browse" |
| 118 | onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })} | ||
| 119 | > | ||
| 120 | <PlusIcon className="size-4" /> | ||
| 121 | Add an integration | ||
| 122 | </Link> | ||
| 327 | </Button> | 123 | </Button> |
| 328 | </div> | 124 | </div> |
| 329 | ); | 125 | ); |
| 330 | } | 126 | } |
| 331 | 127 | ||
| 332 | // --------------------------------------------------------------------------- | ||
| 333 | // Preset grid (for inside the Connect dialog) | ||
| 334 | // --------------------------------------------------------------------------- | ||
| 335 | |||
| 336 | type PresetEntry = { | ||
| 337 | preset: IntegrationPreset; | ||
| 338 | pluginKey: string; | ||
| 339 | pluginLabel: string; | ||
| 340 | }; | ||
| 341 | |||
| 342 | const CATALOG_KIND_LABEL: Record<CatalogKind, string> = { | ||
| 343 | mcp: "MCP", | ||
| 344 | openapi: "OpenAPI", | ||
| 345 | graphql: "GraphQL", | ||
| 346 | }; | ||
| 347 | |||
| 348 | function PresetGrid(props: { | ||
| 349 | plugins: readonly IntegrationPlugin[]; | ||
| 350 | onPick: () => void; | ||
| 351 | /** Controlled filter query forwarded from the dialog's unified | ||
| 352 | * search/URL input. Empty string disables filtering. */ | ||
| 353 | searchQuery?: string; | ||
| 354 | }) { | ||
| 355 | const navigate = useNavigate(); | ||
| 356 | const allPresets = useMemo(() => { | ||
| 357 | const entries: PresetEntry[] = []; | ||
| 358 | for (const plugin of props.plugins) { | ||
| 359 | for (const preset of plugin.presets ?? []) { | ||
| 360 | entries.push({ | ||
| 361 | preset, | ||
| 362 | pluginKey: plugin.key, | ||
| 363 | pluginLabel: plugin.label, | ||
| 364 | }); | ||
| 365 | } | ||
| 366 | } | ||
| 367 | return entries; | ||
| 368 | }, [props.plugins]); | ||
| 369 | |||
| 370 | const query = (props.searchQuery ?? "").trim(); | ||
| 371 | |||
| 372 | const filtered = useMemo(() => { | ||
| 373 | const q = query.toLowerCase(); | ||
| 374 | if (q.length === 0) return allPresets; | ||
| 375 | return allPresets.filter(({ preset, pluginLabel }) => { | ||
| 376 | const corpus = | ||
| 377 | `${preset.name} ${preset.summary ?? ""} ${preset.family ?? ""} ${preset.specFormat ?? ""} ${pluginLabel}`.toLowerCase(); | ||
| 378 | return corpus.includes(q); | ||
| 379 | }); | ||
| 380 | }, [allPresets, query]); | ||
| 381 | |||
| 382 | // Long-tail search over the public integrations.sh registry, shown under the | ||
| 383 | // curated presets. Domains a preset already covers stay preset-only. | ||
| 384 | const catalog = useCatalogSearch(query); | ||
| 385 | const excludeDomains = useMemo(() => presetDomains(props.plugins), [props.plugins]); | ||
| 386 | const availableKinds = useMemo(() => availableCatalogKinds(props.plugins), [props.plugins]); | ||
| 387 | const catalogEntries = useMemo( | ||
| 388 | () => filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds }), | ||
| 389 | [catalog.entries, excludeDomains, availableKinds], | ||
| 390 | ); | ||
| 391 | const [resolvingDomain, setResolvingDomain] = useState<string | null>(null); | ||
| 392 | const [catalogError, setCatalogError] = useState<string | null>(null); | ||
| 393 | |||
| 394 | const pickCatalogEntry = useCallback( | ||
| 395 | async (entry: CatalogSearchEntry) => { | ||
| 396 | if (resolvingDomain !== null) return; | ||
| 397 | setResolvingDomain(entry.domain); | ||
| 398 | setCatalogError(null); | ||
| 399 | const exit = await Effect.runPromiseExit(resolveConnectTarget(entry.domain, entry.kinds)); | ||
| 400 | setResolvingDomain(null); | ||
| 401 | if (Exit.isFailure(exit)) { | ||
| 402 | setCatalogError( | ||
| 403 | `Couldn't load connect details for ${entry.domain}. Paste a URL above instead.`, | ||
| 404 | ); | ||
| 405 | return; | ||
| 406 | } | ||
| 407 | const target = exit.value; | ||
| 408 | if (!target) { | ||
| 409 | setCatalogError( | ||
| 410 | `No connectable endpoint is on record for ${entry.domain}. Paste a URL above to detect one.`, | ||
| 411 | ); | ||
| 412 | return; | ||
| 413 | } | ||
| 414 | trackEvent("integration_add_started", { | ||
| 415 | plugin_key: target.kind, | ||
| 416 | via: "catalog", | ||
| 417 | catalog_domain: entry.domain, | ||
| 418 | }); | ||
| 419 | props.onPick(); | ||
| 420 | void navigate({ | ||
| 421 | to: "/{-$orgSlug}/integrations/add/$pluginKey", | ||
| 422 | params: { pluginKey: target.kind }, | ||
| 423 | search: { url: target.url, ...(target.slug ? { namespace: target.slug } : {}) }, | ||
| 424 | }); | ||
| 425 | }, | ||
| 426 | [navigate, props, resolvingDomain], | ||
| 427 | ); | ||
| 428 | |||
| 429 | const showCatalogSection = query.length > 0 && (catalogEntries.length > 0 || catalog.loading); | ||
| 430 | |||
| 431 | if (allPresets.length === 0) return null; | ||
| 432 | |||
| 433 | return ( | ||
| 434 | <div className="flex min-w-0 flex-col gap-2"> | ||
| 435 | <p className="text-xs font-medium text-foreground/80">Popular integrations</p> | ||
| 436 | <CardStack className="min-w-0"> | ||
| 437 | {/* Fixed height keeps the dialog stable as the user filters; the | ||
| 438 | * inner area scrolls when the list overflows and shows an empty | ||
| 439 | * state when no presets match. */} | ||
| 440 | <CardStackContent className="h-64 overflow-y-auto"> | ||
| 441 | {filtered.length === 0 && !showCatalogSection ? ( | ||
| 442 | <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-6 text-center"> | ||
| 443 | <p className="text-sm text-muted-foreground">No matching integrations</p> | ||
| 444 | <p className="text-xs text-muted-foreground/70"> | ||
| 445 | Paste a URL above to auto-detect, or pick an integration type manually. | ||
| 446 | </p> | ||
| 447 | </div> | ||
| 448 | ) : ( | ||
| 449 | filtered.map(({ preset, pluginKey, pluginLabel }) => { | ||
| 450 | const search: Record<string, string> = { preset: preset.id }; | ||
| 451 | if (preset.url) search.url = preset.url; | ||
| 452 | return ( | ||
| 453 | <CardStackEntry key={`${pluginKey}-${preset.id}`} asChild> | ||
| 454 | <Link | ||
| 455 | to="/{-$orgSlug}/integrations/add/$pluginKey" | ||
| 456 | params={{ pluginKey }} | ||
| 457 | search={search} | ||
| 458 | onClick={() => { | ||
| 459 | trackEvent("integration_add_started", { | ||
| 460 | plugin_key: pluginKey, | ||
| 461 | via: "preset", | ||
| 462 | preset_id: preset.id, | ||
| 463 | }); | ||
| 464 | props.onPick(); | ||
| 465 | }} | ||
| 466 | > | ||
| 467 | <CardStackEntryMedia> | ||
| 468 | <PresetIcon | ||
| 469 | {...(preset.icon ? { icon: preset.icon } : {})} | ||
| 470 | {...(preset.fallbackIcon ? { fallbackSrc: preset.fallbackIcon } : {})} | ||
| 471 | className="size-5 object-contain" | ||
| 472 | fallback={ | ||
| 473 | <svg viewBox="0 0 16 16" className="size-3.5" fill="none"> | ||
| 474 | <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" /> | ||
| 475 | </svg> | ||
| 476 | } | ||
| 477 | /> | ||
| 478 | </CardStackEntryMedia> | ||
| 479 | <CardStackEntryContent> | ||
| 480 | <CardStackEntryTitle>{preset.name}</CardStackEntryTitle> | ||
| 481 | <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription> | ||
| 482 | </CardStackEntryContent> | ||
| 483 | <CardStackEntryActions> | ||
| 484 | <Badge variant="secondary">{pluginLabel}</Badge> | ||
| 485 | </CardStackEntryActions> | ||
| 486 | </Link> | ||
| 487 | </CardStackEntry> | ||
| 488 | ); | ||
| 489 | }) | ||
| 490 | )} | ||
| 491 | {showCatalogSection && ( | ||
| 492 | <> | ||
| 493 | {catalogEntries.map((entry) => ( | ||
| 494 | <CardStackEntry key={`catalog-${entry.domain}`} asChild> | ||
| 495 | <Button | ||
| 496 | type="button" | ||
| 497 | variant="ghost" | ||
| 498 | onClick={() => void pickCatalogEntry(entry)} | ||
| 499 | className="h-auto w-full justify-start rounded-none text-left font-normal whitespace-normal hover:bg-accent/40 dark:hover:bg-accent/40" | ||
| 500 | > | ||
| 501 | <CardStackEntryMedia> | ||
| 502 | <img | ||
| 503 | src={catalogLogoUrl(entry.domain, 10)} | ||
| 504 | alt="" | ||
| 505 | className="size-5 object-contain" | ||
| 506 | loading="lazy" | ||
| 507 | /> | ||
| 508 | </CardStackEntryMedia> | ||
| 509 | <CardStackEntryContent> | ||
| 510 | <CardStackEntryTitle>{entry.domain}</CardStackEntryTitle> | ||
| 511 | <CardStackEntryDescription>{entry.description}</CardStackEntryDescription> | ||
| 512 | </CardStackEntryContent> | ||
| 513 | <CardStackEntryActions> | ||
| 514 | {resolvingDomain === entry.domain ? ( | ||
| 515 | <span className="text-xs text-muted-foreground">Resolving…</span> | ||
| 516 | ) : ( | ||
| 517 | entry.kinds.map((kind) => ( | ||
| 518 | <Badge key={kind} variant="secondary"> | ||
| 519 | {CATALOG_KIND_LABEL[kind]} | ||
| 520 | </Badge> | ||
| 521 | )) | ||
| 522 | )} | ||
| 523 | </CardStackEntryActions> | ||
| 524 | </Button> | ||
| 525 | </CardStackEntry> | ||
| 526 | ))} | ||
| 527 | {catalog.loading && | ||
| 528 | catalogEntries.length === 0 && | ||
| 529 | Array.from({ length: 3 }).map((_, i) => ( | ||
| 530 | <div key={`catalog-skeleton-${i}`} className="flex items-center gap-3 px-4 py-3"> | ||
| 531 | <Skeleton className="size-5 shrink-0 rounded-md" /> | ||
| 532 | <div className="flex min-w-0 flex-1 flex-col gap-1.5"> | ||
| 533 | <Skeleton className="h-3.5" style={{ width: `${35 + ((i * 13) % 25)}%` }} /> | ||
| 534 | <Skeleton className="h-3" style={{ width: `${55 + ((i * 9) % 20)}%` }} /> | ||
| 535 | </div> | ||
| 536 | <Skeleton className="h-5 w-14 rounded-full" /> | ||
| 537 | </div> | ||
| 538 | ))} | ||
| 539 | </> | ||
| 540 | )} | ||
| 541 | </CardStackContent> | ||
| 542 | </CardStack> | ||
| 543 | {catalogError && <p className="text-xs text-destructive">{catalogError}</p>} | ||
| 544 | </div> | ||
| 545 | ); | ||
| 546 | } | ||
| 547 | |||
| 548 | // --------------------------------------------------------------------------- | 128 | // --------------------------------------------------------------------------- |
| 549 | // Integration grid — flat list of catalog integrations, click-through to detail | 129 | // Integration grid — flat list of catalog integrations, click-through to detail |
| 550 | // --------------------------------------------------------------------------- | 130 | // --------------------------------------------------------------------------- |
| @@ -8,6 +8,14 @@ const SearchParams = Schema.toStandardSchemaV1( · 7 unmodified lines | |||
| 8 | url: Schema.optional(Schema.String), | 8 | url: Schema.optional(Schema.String), |
| 9 | preset: Schema.optional(Schema.String), | 9 | preset: Schema.optional(Schema.String), |
| 10 | namespace: Schema.optional(Schema.String), | 10 | namespace: Schema.optional(Schema.String), |
| 11 | // Registry-declared credential placement, e.g. "Authorization: {api_key}". | ||
| 12 | // Carried for surfaces whose connect target can't describe its own auth | ||
| 13 | // (GraphQL endpoints have no spec document). | ||
| 14 | authHeader: Schema.optional(Schema.String), | ||
| 15 | authNote: Schema.optional(Schema.String), | ||
| 16 | authKind: Schema.optional(Schema.String), | ||
| 17 | // JSON-encoded RFC 6902 patch the registry says to apply to the spec. | ||
| 18 | specOverrides: Schema.optional(Schema.String), | ||
| 11 | }), | 19 | }), |
| 12 | ); | 20 | ); |
| 13 | 21 | ||
| @@ -15,9 +23,19 @@ export const Route = createFileRoute("/{-$orgSlug}/integrations/add/$pluginKey") · 1 unmodified line | |||
| 15 | validateSearch: SearchParams, | 23 | validateSearch: SearchParams, |
| 16 | component: () => { | 24 | component: () => { |
| 17 | const { pluginKey } = Route.useParams(); | 25 | const { pluginKey } = Route.useParams(); |
| 18 | const { url, preset, namespace } = Route.useSearch(); | 26 | const { url, preset, namespace, authHeader, authNote, authKind, specOverrides } = |
| 27 | Route.useSearch(); | ||
| 19 | return ( | 28 | return ( |
| 20 | <AddIntegrationPage pluginKey={pluginKey} url={url} preset={preset} namespace={namespace} /> | 29 | <AddIntegrationPage |
| 30 | pluginKey={pluginKey} | ||
| 31 | url={url} | ||
| 32 | preset={preset} | ||
| 33 | namespace={namespace} | ||
| 34 | authHeader={authHeader} | ||
| 35 | authNote={authNote} | ||
| 36 | authKind={authKind} | ||
| 37 | specOverrides={specOverrides} | ||
| 38 | /> | ||
| 21 | ); | 39 | ); |
| 22 | }, | 40 | }, |
| 23 | }); | 41 | }); |
| @@ -0,0 +1,7 @@ | |||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | ||
| 2 | |||
| 3 | import { IntegrationBrowsePage } from "../pages/integration-browse"; | ||
| 4 | |||
| 5 | export const Route = createFileRoute("/{-$orgSlug}/integrations/browse")({ | ||
| 6 | component: IntegrationBrowsePage, | ||
| 7 | }); | ||
| @@ -18,6 +18,7 @@ import { Route as DotPoliciesRouteImport } from './policies' · 17 unmodified lines | |||
| 18 | import { Route as DotArtifactsRouteImport } from './artifacts' | 18 | import { Route as DotArtifactsRouteImport } from './artifacts' |
| 19 | import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' | 19 | import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug' |
| 20 | import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' | 20 | import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId' |
| 21 | import { Route as DotIntegrationsDotbrowseRouteImport } from './integrations.browse' | ||
| 21 | import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' | 22 | import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace' |
| 22 | import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug' | 23 | import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug' |
| 23 | import { Route as DotArtifactsDotartifactIdRouteImport } from './artifacts.$artifactId' | 24 | import { Route as DotArtifactsDotartifactIdRouteImport } from './artifacts.$artifactId' |
| @@ -70,6 +71,12 @@ const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({ · 46 unmodified lines | |||
| 70 | path: '/{-$orgSlug}/resume/$executionId', | 71 | path: '/{-$orgSlug}/resume/$executionId', |
| 71 | getParentRoute: () => rootRouteImport, | 72 | getParentRoute: () => rootRouteImport, |
| 72 | } as any) | 73 | } as any) |
| 74 | const DotIntegrationsDotbrowseRoute = | ||
| 75 | DotIntegrationsDotbrowseRouteImport.update({ | ||
| 76 | id: '/{-$orgSlug}/integrations/browse', | ||
| 77 | path: '/{-$orgSlug}/integrations/browse', | ||
| 78 | getParentRoute: () => rootRouteImport, | ||
| 79 | } as any) | ||
| 73 | const DotIntegrationsDotnamespaceRoute = | 80 | const DotIntegrationsDotnamespaceRoute = |
| 74 | DotIntegrationsDotnamespaceRouteImport.update({ | 81 | DotIntegrationsDotnamespaceRouteImport.update({ |
| 75 | id: '/{-$orgSlug}/integrations/$namespace', | 82 | id: '/{-$orgSlug}/integrations/$namespace', |
| @@ -112,6 +119,7 @@ export interface FileRoutesByFullPath { · 36 unmodified lines | |||
| 112 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute | 119 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute |
| 113 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute | 120 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute |
| 114 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute | 121 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 122 | '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute | ||
| 115 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute | 123 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 116 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute | 124 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute |
| 117 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute | 125 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute |
| @@ -128,6 +136,7 @@ export interface FileRoutesByTo { · 10 unmodified lines | |||
| 128 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute | 136 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute |
| 129 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute | 137 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute |
| 130 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute | 138 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 139 | '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute | ||
| 131 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute | 140 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 132 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute | 141 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute |
| 133 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute | 142 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute |
| @@ -145,6 +154,7 @@ export interface FileRoutesById { · 11 unmodified lines | |||
| 145 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute | 154 | '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute |
| 146 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute | 155 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute |
| 147 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute | 156 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| 157 | '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute | ||
| 148 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute | 158 | '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute |
| 149 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute | 159 | '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute |
| 150 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute | 160 | '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute |
| @@ -163,6 +173,7 @@ export interface FileRouteTypes { · 12 unmodified lines | |||
| 163 | | '/{-$orgSlug}/artifacts/$artifactId' | 173 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 164 | | '/{-$orgSlug}/connect/$integrationSlug' | 174 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 165 | | '/{-$orgSlug}/integrations/$namespace' | 175 | | '/{-$orgSlug}/integrations/$namespace' |
| 176 | | '/{-$orgSlug}/integrations/browse' | ||
| 166 | | '/{-$orgSlug}/resume/$executionId' | 177 | | '/{-$orgSlug}/resume/$executionId' |
| 167 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 178 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 168 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 179 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -179,6 +190,7 @@ export interface FileRouteTypes { · 10 unmodified lines | |||
| 179 | | '/{-$orgSlug}/artifacts/$artifactId' | 190 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 180 | | '/{-$orgSlug}/connect/$integrationSlug' | 191 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 181 | | '/{-$orgSlug}/integrations/$namespace' | 192 | | '/{-$orgSlug}/integrations/$namespace' |
| 193 | | '/{-$orgSlug}/integrations/browse' | ||
| 182 | | '/{-$orgSlug}/resume/$executionId' | 194 | | '/{-$orgSlug}/resume/$executionId' |
| 183 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 195 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 184 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 196 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -195,6 +207,7 @@ export interface FileRouteTypes { · 10 unmodified lines | |||
| 195 | | '/{-$orgSlug}/artifacts/$artifactId' | 207 | | '/{-$orgSlug}/artifacts/$artifactId' |
| 196 | | '/{-$orgSlug}/connect/$integrationSlug' | 208 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 197 | | '/{-$orgSlug}/integrations/$namespace' | 209 | | '/{-$orgSlug}/integrations/$namespace' |
| 210 | | '/{-$orgSlug}/integrations/browse' | ||
| 198 | | '/{-$orgSlug}/resume/$executionId' | 211 | | '/{-$orgSlug}/resume/$executionId' |
| 199 | | '/{-$orgSlug}/toolkits/$toolkitSlug' | 212 | | '/{-$orgSlug}/toolkits/$toolkitSlug' |
| 200 | | '/{-$orgSlug}/integrations/add/$pluginKey' | 213 | | '/{-$orgSlug}/integrations/add/$pluginKey' |
| @@ -211,6 +224,7 @@ export interface RootRouteChildren { · 10 unmodified lines | |||
| 211 | DotIndexRoute: typeof DotIndexRoute | 224 | DotIndexRoute: typeof DotIndexRoute |
| 212 | DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute | 225 | DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute |
| 213 | DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute | 226 | DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute |
| 227 | DotIntegrationsDotbrowseRoute: typeof DotIntegrationsDotbrowseRoute | ||
| 214 | DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute | 228 | DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute |
| 215 | DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute | 229 | DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute |
| 216 | DotPluginsDotpluginIdDotsplatRoute: typeof DotPluginsDotpluginIdDotsplatRoute | 230 | DotPluginsDotpluginIdDotsplatRoute: typeof DotPluginsDotpluginIdDotsplatRoute |
| @@ -281,6 +295,13 @@ declare module '@tanstack/react-router' { · 64 unmodified lines | |||
| 281 | preLoaderRoute: typeof DotResumeDotexecutionIdRouteImport | 295 | preLoaderRoute: typeof DotResumeDotexecutionIdRouteImport |
| 282 | parentRoute: typeof rootRouteImport | 296 | parentRoute: typeof rootRouteImport |
| 283 | } | 297 | } |
| 298 | '/{-$orgSlug}/integrations/browse': { | ||
| 299 | id: '/{-$orgSlug}/integrations/browse' | ||
| 300 | path: '/{-$orgSlug}/integrations/browse' | ||
| 301 | fullPath: '/{-$orgSlug}/integrations/browse' | ||
| 302 | preLoaderRoute: typeof DotIntegrationsDotbrowseRouteImport | ||
| 303 | parentRoute: typeof rootRouteImport | ||
| 304 | } | ||
| 284 | '/{-$orgSlug}/integrations/$namespace': { | 305 | '/{-$orgSlug}/integrations/$namespace': { |
| 285 | id: '/{-$orgSlug}/integrations/$namespace' | 306 | id: '/{-$orgSlug}/integrations/$namespace' |
| 286 | path: '/{-$orgSlug}/integrations/$namespace' | 307 | path: '/{-$orgSlug}/integrations/$namespace' |
| @@ -353,6 +374,7 @@ const rootRouteChildren: RootRouteChildren = { · 66 unmodified lines | |||
| 353 | DotIndexRoute: DotIndexRoute, | 374 | DotIndexRoute: DotIndexRoute, |
| 354 | DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute, | 375 | DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute, |
| 355 | DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute, | 376 | DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute, |
| 377 | DotIntegrationsDotbrowseRoute: DotIntegrationsDotbrowseRoute, | ||
| 356 | DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute, | 378 | DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute, |
| 357 | DotIntegrationsDotaddDotpluginKeyRoute: | 379 | DotIntegrationsDotaddDotpluginKeyRoute: |
| 358 | DotIntegrationsDotaddDotpluginKeyRoute, | 380 | DotIntegrationsDotaddDotpluginKeyRoute, |
| @@ -0,0 +1,515 @@ | |||
| 1 | # Kill the plugin system — in-place migration plan | ||
| 2 | |||
| 3 | Decision (Rhys, 2026-07-27): abandon the big-bang rework | ||
| 4 | (`UsefulSoftwareCo/executor-rework`, cloned read-only at | ||
| 5 | `.reference/executor-rework`); fix this repo in place over a sequence of PRs, | ||
| 6 | each keeping format/lint/typecheck/test/e2e green. Priority order: **data | ||
| 7 | models first**, then the plugin kill, then the concept-package (apps) model, | ||
| 8 | then the minimal SDK / platform split. | ||
| 9 | |||
| 10 | RESEQUENCED (Rhys, 2026-07-28): **Phase 3 (apps/concept-package model) now | ||
| 11 | lands BEFORE Phase 2 (plugin kill), side by side with the protocol plugins.** | ||
| 12 | Rationale: the new model's tables are greenfield and additive — landing it | ||
| 13 | first means the legacy apps tables never need an in-place migration; the whole | ||
| 14 | model swaps underneath while openapi/mcp/graphql keep running as plugins. | ||
| 15 | Structurally sound because (a) the kernel + MCP host are already plugin-free, | ||
| 16 | so the new model's runtime never touched the registry, and (b) the apps plugin | ||
| 17 | already exercises every extension point, so the new model ships as the apps | ||
| 18 | plugin's new internals wearing the same costume. When 2.3 later converts the | ||
| 19 | four kinds, apps converts as a stable, proven model instead of mid-rewrite. | ||
| 20 | Phase 0 and 2.1 remain freely parallel — they don't touch apps. | ||
| 21 | |||
| 22 | Hard line: side-by-side means new-apps-model alongside the _protocol plugins_, | ||
| 23 | never two app models with dual read paths inside the apps kind. Existing app | ||
| 24 | sources get a one-off migration to published-source form (enumerated | ||
| 25 | invariants, explicit approval, no permanent compatibility path). Accepted | ||
| 26 | cost: the new model's HTTP surface temporarily wears plugin-route ceremony | ||
| 27 | (HttpApiGroup + handler layers) that 2.2/2.6 later deletes — wiring, not | ||
| 28 | model. The authoring SDK (`defineApp`/`defineProvider`/`defineTool`) goes | ||
| 29 | public early; its boundaries take Standard Schema (StandardSchemaV1 & | ||
| 30 | StandardJSONSchemaV1), never Effect Schema, and Phase 4's vocabulary rules | ||
| 31 | (opaque owner refs, no "tenant") bind on it from day one. | ||
| 32 | |||
| 33 | ## Diagnosis (verified against the code) | ||
| 34 | |||
| 35 | **The plugin system is a fiction.** `packages/core/sdk/src/plugin.ts` (~850 | ||
| 36 | lines, ~20 optional hooks) pretends the plugin set is open, but: | ||
| 37 | |||
| 38 | - `Integration.kind` _is_ the plugin id string; all hot paths dispatch via | ||
| 39 | `runtimes.get(row.plugin_id)` (`executor.ts`). | ||
| 40 | - Core and React hardcode the closed set anyway: `migration-spec.ts` hardcodes | ||
| 41 | plugin ids; `packages/react` has `KIND_TO_PLUGIN_KEY` maps and an | ||
| 42 | `if (pluginId === "toolkits")` route branch. | ||
| 43 | - Nobody ever shipped a fourth protocol plugin, and the two provider plugins | ||
| 44 | (google/microsoft) were already collapsed into openapi (#1366). | ||
| 45 | - Whole packages exist only to support the open set: `plugin-routes.ts` | ||
| 46 | type-union machinery, `@executor-js/config`'s jsonc+jiti loader, | ||
| 47 | `packages/core/vite-plugin`'s virtual client module. The jsonc plugin path is | ||
| 48 | vestigial — no `executor.jsonc` exists anywhere in the repo, no docs mention | ||
| 49 | it. | ||
| 50 | - Toolkits (~880 LoC of pure curation + the single `toolPolicyProvider` slot) | ||
| 51 | and apps (~6.8k custom-tools gateway) are first-party capabilities wearing a | ||
| 52 | plugin costume. Kernel packages and `packages/hosts/mcp` have zero plugin | ||
| 53 | references — the indirection is not load-bearing for the real surfaces. | ||
| 54 | |||
| 55 | **The data model is connection-first, not user-first** | ||
| 56 | (`packages/core/sdk/src/core-schema.ts`): | ||
| 57 | |||
| 58 | - No user/subject table. `subject` is an opaque partition-key string smeared | ||
| 59 | across owned tables; a user "exists" only if they have a connection row. | ||
| 60 | - Disconnect is a hard delete (`connectionsRemove`) — a signed-out user is | ||
| 61 | bit-identical to one who never connected. "Which users are signed out" is | ||
| 62 | structurally unanswerable. | ||
| 63 | - Every Executor is bound to one `{tenant, subject}` via `owner-policy.ts`; | ||
| 64 | there is no cross-subject read path, so no admin view. | ||
| 65 | - Toolkit state lives in generic `plugin_storage` KV, not core tables. | ||
| 66 | - Cloud's real identity tables (WorkOS accounts/organizations) have no join to | ||
| 67 | `subject`. | ||
| 68 | |||
| 69 | **What to salvage from the rework** (its model docs are the valuable artifact; | ||
| 70 | its implementation ported only ~10 of 131 legacy e2e scenarios — that's why it | ||
| 71 | failed, and why this plan is incremental): | ||
| 72 | |||
| 73 | - Apps-as-published-source: `defineApp`/`defineProvider`/`defineTool`/ | ||
| 74 | `defineToolGroup`, immutable content-hashed versions, manifest derived by | ||
| 75 | executing the source, rollback = pointer move. | ||
| 76 | - Generators: OpenAPI/MCP specs generate the _same_ app source a human would | ||
| 77 | write, with a re-verifiable `GeneratorClaim` (first-party vs third-party). | ||
| 78 | - Minimal SDK boundary: `createExecutor`/`createRemoteExecutor`, no Effect | ||
| 79 | crossing the boundary, MCP exposure returns a standard MCP SDK server. | ||
| 80 | - Custody separation: secrets never on the wire; opaque owner refs. | ||
| 81 | |||
| 82 | ## Constraints discovered (do not relearn these) | ||
| 83 | |||
| 84 | 1. **Published packages make this semver-major.** `scripts/publish-packages.ts` | ||
| 85 | publishes `@executor-js/{config,sdk}` and plugins | ||
| 86 | `{example,file-secrets,graphql,keychain,mcp,onepassword,openapi}` — killing | ||
| 87 | `definePlugin` is a breaking release for seven packages. Changesets + | ||
| 88 | deprecation story required; `publish-packages.ts`, `pkg-pr-new.yml`, | ||
| 89 | `check-changelog-stubs.ts`, and `examples/*` all have hardcoded lists. | ||
| 90 | 2. **`plugin_id = 'google'` is a live persisted value with no runtime.** The | ||
| 91 | provider-service-split migration skips orgs with missing spec blobs | ||
| 92 | (`sqlite.test.ts` asserts survivors). The `googleDiscovery → "google"` | ||
| 93 | entries in React are compensating, not dangling. The closed SourceType | ||
| 94 | switch must map `"google"`/`"googleDiscovery"` → openapi or degraded rows | ||
| 95 | become hard failures. | ||
| 96 | 3. **FumaDB is not versioned in practice.** All hosts pin `version: "1.0.0"`; | ||
| 97 | runtime bring-up is `ensureDrizzleRuntimeSchemaFromTables` (CREATE TABLE IF | ||
| 98 | NOT EXISTS + additive nullable ALTERs). New tables/nullable columns are free | ||
| 99 | on local/self-host/D1. **Cloud is the exception**: hand-edit | ||
| 100 | `apps/cloud/src/db/executor-schema.ts` + drizzle-kit migration + | ||
| 101 | `db:migrate:prod`; `db.schema.test.ts` enforces the mirror, so "forgot | ||
| 102 | cloud" fails tests. | ||
| 103 | 4. **93 e2e files import `composePluginApi`** purely to type the client. Keep | ||
| 104 | it as a deprecated argument-ignoring shim returning the static | ||
| 105 | `FullExecutorApi`; sweep the call sites mechanically in one final PR. | ||
| 106 | 5. **Keep the `plugin_id` column name** (four tables + blob namespace format | ||
| 107 | `o:<tenant>/<plugin_id>`). Renaming to `source_kind` is a cross-host | ||
| 108 | migration for zero behavior. Retype on the read side only. | ||
| 109 | 6. **URL contracts:** `/integrations/add/$pluginKey` is navigated directly by | ||
| 110 | e2e; `/plugins/desktop-settings/` is asserted by the desktop-packaged suite | ||
| 111 | and baked into a tool's returned URL. Keep shapes / add redirects. | ||
| 112 | 7. **CI gaps:** PRs run cloud (4 shards) + selfhost e2e only; local/desktop | ||
| 113 | suites gate push-to-main. PRs touching desktop-settings or the local loader | ||
| 114 | need a manual local e2e run pre-merge. | ||
| 115 | 8. **Toolkit selection filters _connections_, not just tools** | ||
| 116 | (`connectionsList` goes through the active policy provider). Preserve when | ||
| 117 | moving toolkits to core, or the toolkit e2e suites go red. | ||
| 118 | |||
| 119 | ## Phase 0 — declutter (3 PRs) | ||
| 120 | |||
| 121 | - **0.1** Delete empty `packages/plugins/{google,microsoft}`; drop the unused | ||
| 122 | `@executor-js/config` dep from openapi/mcp/graphql; move | ||
| 123 | `provider-service-split` to `packages/migrations/` (keep package name — it's | ||
| 124 | imported by three host data-migration registries). In the React maps, keep | ||
| 125 | `google`/`googleDiscovery` → openapi with a comment citing constraint 2. | ||
| 126 | - **0.2** Bake google/microsoft catalogs + spec-format adapters in as openapi | ||
| 127 | defaults; strip the re-threading from all four host configs. Gate: | ||
| 128 | `provider-plugins-ui`, `google-health-checks`, `microsoft-*` scenarios. | ||
| 129 | - **0.3** Move desktop-settings (438-line Electron-IPC client, not a | ||
| 130 | one-liner) to a first-class desktop-only route in `packages/app`; update the | ||
| 131 | static tool's URL and the desktop-packaged e2e selector; delete the plugin. | ||
| 132 | |||
| 133 | ## Phase 1 — data model (4 PRs) — FIRST PRIORITY | ||
| 134 | |||
| 135 | Design choices (least-invasive, verified feasible): | ||
| 136 | |||
| 137 | - The new **subject table is `tenant`-scoped, not owner-scoped** — readable by | ||
| 138 | any executor bound to the tenant with **zero policy changes** (the | ||
| 139 | `integration` table already proves the pattern). | ||
| 140 | - Cross-subject connection reads: add `reach: "bound" | "tenant"` to | ||
| 141 | `ExecutorOwnerPolicyContext` — widens `ownerVisibilityCondition` for reads | ||
| 142 | only; `assertOwnerWritable`/`assertOwnerPatch` untouched, so admin reach can | ||
| 143 | never write. Only two construction sites exist (`executor.ts`, | ||
| 144 | `test-config.ts`). | ||
| 145 | - Identity stays with the hosts (WorkOS / Better Auth member lists already | ||
| 146 | exist behind `AccountProvider.listMembers`); the subject table is the missing | ||
| 147 | **join** between host identity and executor connections, not a new identity | ||
| 148 | system. Tolerate `subject = null` (pure-org) and `subject = "local"`. | ||
| 149 | |||
| 150 | PRs: | ||
| 151 | |||
| 152 | - **1.1** Add tenant-scoped `subject` table (id, tenant, external_id, | ||
| 153 | created_at, last_seen_at, status) to `coreTables` + cloud mirror + one | ||
| 154 | drizzle migration. No readers yet. | ||
| 155 | - **1.2** Populate: upsert in `makeScopedExecutor` | ||
| 156 | (`packages/core/api/src/server/scoped-executor.ts` — every HTTP request and | ||
| 157 | MCP session on all four hosts passes through it) and at connection-create. | ||
| 158 | Add to `purgeOrganizationData` + its `TENANT_TABLES` test. | ||
| 159 | - **1.3** `reach` mode + `admin.listSubjectsWithConnections`-shaped read on a | ||
| 160 | derived handle. Policy test proving admin reach cannot write. Scope includes | ||
| 161 | (customer ask, owner.com 2026-07-27): per-subject reads ("connections for | ||
| 162 | subject X"), not just the aggregate list, and a privileged org-level API key | ||
| 163 | (keys already carry `{accountId, organizationId}` — | ||
| 164 | `apps/cloud/src/auth/api-keys.ts`) that resolves to the tenant-reach | ||
| 165 | read-only handle so customers can inspect connections programmatically. | ||
| 166 | - **1.4** Connection lifecycle: nullable `status` column | ||
| 167 | (active/disconnected/revoked/expired) plus `status_changed_at`; disconnect | ||
| 168 | writes status instead of deleting. Wide blast radius — status predicate | ||
| 169 | through `findConnectionRow`, `connectionsList`, credential resolution, | ||
| 170 | `toolsList`, org purge; own e2e sweep (`connections-*`, `health-checks*`, | ||
| 171 | `oauth-*`). PRIORITY RAISED (customer, 2026-07-28): the customer's | ||
| 172 | block-startup-until-reconnect flow needs connected-then-disconnected to be | ||
| 173 | distinguishable from never-connected, which hard-delete makes impossible | ||
| 174 | today; expose status + statusChangedAt through the admin API when this | ||
| 175 | lands. | ||
| 176 | |||
| 177 | Toolkits data does NOT move in this phase (it moves with its reader in 2.4 — | ||
| 178 | moving it earlier means either rewriting the plugin storage layer twice or a | ||
| 179 | forbidden dual read path). | ||
| 180 | |||
| 181 | ### Framing: product view vs platform view (Rhys, 2026-07-27) | ||
| 182 | |||
| 183 | The two-view model for consumer-facing surfaces, longer-term: | ||
| 184 | |||
| 185 | - **Product view** — the bound, per-individual executor every request gets | ||
| 186 | today (their connections, their tools). The default; unchanged. | ||
| 187 | - **Platform view** — an operator-level view attached alongside it: read-only, | ||
| 188 | cross-user, explicitly reached for with elevated access (an escape hatch, | ||
| 189 | never a mode product view drifts into). The `reach: "bound" | "tenant"` | ||
| 190 | split in 1.3 is this model's first implementation; 1.5's admin API and the | ||
| 191 | privileged key are its first surface. Later platform-view candidates (usage, | ||
| 192 | policy overview, cross-user health) land on the same handle. | ||
| 193 | |||
| 194 | Cloud wrinkle: cloud consumers like owner.com don't bring their own owner ids — | ||
| 195 | their users authenticate through our WorkOS, so `subject` is _our_ accountId | ||
| 196 | and the customer correlates on their side. The opaque-owner-ids-in-the- | ||
| 197 | customer's-id-space story fully applies only to embedded/API consumers; for | ||
| 198 | cloud, the platform view (+ `external_id` on the subject table) is what makes | ||
| 199 | the correlation tractable. | ||
| 200 | |||
| 201 | ### Fast path: owner.com admin-visibility ask (ships from Phase 1 alone) | ||
| 202 | |||
| 203 | The owner.com ask (admin inspects users' connections; API key that can do the | ||
| 204 | same; available-vs-connected icon grid; direct connect links) needs only | ||
| 205 | 1.1 → 1.2 → 1.3 plus a thin delivery layer — no dependency on Phase 0 or the | ||
| 206 | plugin kill. Delivery PRs on top of 1.3: | ||
| 207 | |||
| 208 | - **1.5** Public admin API surface: `GET .../admin/users`, | ||
| 209 | `GET .../admin/users/:externalId/connections` (integration slug + status + | ||
| 210 | last_health only — never item_ids/oauth fields), gated on the privileged key | ||
| 211 | / admin membership. Public paths say `users`, not `subjects`/`tenants` — see | ||
| 212 | "Vocabulary" below. The available-vs-connected diff is computable client-side | ||
| 213 | from this plus the existing integration catalog. | ||
| 214 | - **1.6** Connect deep links: stable | ||
| 215 | `/connect/:integrationSlug`-shaped URL that lands the arriving user in the | ||
| 216 | connect flow for that integration (auth-aware redirect). Uses integration | ||
| 217 | slugs, not plugin routes, so it survives Phase 2.5 unchanged. | ||
| 218 | |||
| 219 | 1.4 (lifecycle status) upgrades the grid from binary connected/not to | ||
| 220 | expired/disconnected states but is not a blocker for first ship. | ||
| 221 | |||
| 222 | ## Phase 2 — kill the registry (6 PRs) | ||
| 223 | |||
| 224 | - **2.1** Remove dynamic loading: delete `load-plugins.ts` from | ||
| 225 | `@executor-js/config` (keep the rest — jsonc integration config is a separate | ||
| 226 | feature), drop `loadPluginsFromJsonc` from `apps/local`, delete | ||
| 227 | `packages/core/vite-plugin`, replace `virtual:executor/plugins-client` with a | ||
| 228 | static module in `packages/app` (5 `__root.tsx` importers + 3 ambient | ||
| 229 | `.d.ts`), edit `turbo.json` `dev.dependsOn` + root `prepare`, fix | ||
| 230 | `packages/app/vite.config.ts`'s dangling jsonc path. Breaking changeset. | ||
| 231 | - **2.2** Static route list: `composePluginApi` becomes the deprecated shim | ||
| 232 | (constraint 4); hosts stop passing plugin tuples; delete | ||
| 233 | `apps/{cloud,host-selfhost}/src/plugins.ts` (exist only to hold | ||
| 234 | `PluginExtensionServices` typing). | ||
| 235 | - **2.3** The switch: closed internal `SourceType` interface implemented by | ||
| 236 | openapi/graphql/mcp/**apps (current model, unchanged)** as plain libraries, | ||
| 237 | replacing `runtimes.get`. All four kinds convert together — converting three | ||
| 238 | and leaving apps on the plugin path means carrying dual dispatch; rewriting | ||
| 239 | the apps model in the same PR is what killed the rework. Map | ||
| 240 | `"google"`/`"googleDiscovery"` → openapi. `plugin_id` column name unchanged. | ||
| 241 | - **2.4** Toolkits to core: core tables + one-off migration out of | ||
| 242 | `plugin_storage` (enumerated invariants, no dual path), `toolPolicyProvider` | ||
| 243 | slot → native policy input (one call site, one implementation, one test), | ||
| 244 | ordinary core routes, `activeToolkitSlug` becomes an executor option. | ||
| 245 | Preserve the connection-visibility filter (constraint 8). | ||
| 246 | - **2.5** React: four named kind renderers; move plugin React code (~2.9k LoC, | ||
| 247 | e.g. `AddMcpIntegration.tsx`) into `packages/react` — watch the peer-dep | ||
| 248 | edge for cycles; delete `defineClientPlugin`, the plugins context, both | ||
| 249 | `KIND_TO_PLUGIN_KEY` maps, and `/plugins/$pluginId/$` (with redirects for | ||
| 250 | desktop-settings + toolkits). Keep `/integrations/add/$pluginKey` URL shape. | ||
| 251 | Don't forget the second axis: `useSecretProviderPlugins` (secrets page). | ||
| 252 | - **2.6** Detach `CredentialProvider` from `PluginSpec` (the interface in | ||
| 253 | `provider.ts` is already narrow — secret-store plugins keep it as their | ||
| 254 | seam); delete `plugin.ts`, `plugin-routes.ts`, `plugin-example`, and the | ||
| 255 | shim; mechanical sweep of the 93 e2e files; changesets for all seven | ||
| 256 | published packages. | ||
| 257 | |||
| 258 | ## Phase 3 — custom = concept package (3–4 PRs) — NOW BEFORE PHASE 2 | ||
| 259 | |||
| 260 | (See RESEQUENCED note at top: lands side by side with the protocol plugins, | ||
| 261 | inside the apps plugin's existing costume; Phase 2 kills the registry | ||
| 262 | afterward, with apps converting in 2.3 as an already-stable model.) | ||
| 263 | |||
| 264 | Port the rework's app model in place as the `apps` source kind's new model: | ||
| 265 | published source, manifest derived by execution, content-hashed immutable | ||
| 266 | versions, rollback = pointer move. New tables are additive greenfield (same | ||
| 267 | pattern as 1.1: coreTables + cloud mirror + migration, no readers first). | ||
| 268 | Existing app sources migrate one-off to published-source form — no dual model | ||
| 269 | within the apps kind. Then the OpenAPI/MCP generators + `GeneratorClaim` as an | ||
| 270 | optional on-ramp (spec → generated app source); GraphQL generator later. | ||
| 271 | Long-term option (explicitly out of scope): protocol kinds themselves become | ||
| 272 | generators and "custom" is the only kind. | ||
| 273 | |||
| 274 | ### Feasibility findings (two deep read-throughs, 2026-07-28) | ||
| 275 | |||
| 276 | **Current plugin** (~7.6k LoC): only ~2.65k encodes the old model (store | ||
| 277 | collections, sync state machine, publish/discover/descriptor, routes, | ||
| 278 | console client). ~2.6k is model-agnostic and survives verbatim: all three | ||
| 279 | sandbox executors, the bundler stack, bindings/bridge, authoring + | ||
| 280 | standard-schema, conformance. ~1.9k (hand-rolled git client + source | ||
| 281 | fetchers) hangs on the publish-input decision below. Storage is entirely | ||
| 282 | generic `plugin_storage` rows + content-addressed blobs — zero apps-specific | ||
| 283 | tables or migrations exist, so new tables are pure greenfield. Cloud prod is | ||
| 284 | git-source-only; **source text is never persisted today (only compiled | ||
| 285 | bundles)**, so the one-off migration re-fetches source from the recorded git | ||
| 286 | URLs (fetcher exists). Load-bearing invariants to preserve: tool addresses | ||
| 287 | `tools.<app>.org.published.<name>` + the `<fileSlug>__<key>` naming rule, | ||
| 288 | app-slug↔integration-slug 1:1 guard, publish CAS | ||
| 289 | (`AppPublishConflictError`), tombstone-not-delete, token-in-provider-item | ||
| 290 | (never in the source row). Safety net: `custom-tools.test.ts` (534-line | ||
| 291 | cloud+selfhost console loop), local directory-picker, packed-CLI, desktop | ||
| 292 | e2e, ~2.9k LoC in-package tests. Pre-existing gaps flagged: R2 blobs never | ||
| 293 | GC'd and org purge misses them; cloud silently falls back to in-process | ||
| 294 | executor without the worker loader; host-cloudflare doesn't register apps. | ||
| 295 | |||
| 296 | **Rework model** (portable tiers): T1 model proper ~3.9k LoC (authoring SDK, | ||
| 297 | publish-by-execution, projection, generator verification, storage port, | ||
| 298 | content hashing); T2 generators ~3.2k (fromOpenAPI 1k — complete; fromMCP | ||
| 299 | 0.6k — computed group with real streamable-HTTP client; json-schema engine; | ||
| 300 | byte-for-byte GeneratorClaim verification; render endpoint); T4 host/registry | ||
| 301 | ~2.9k explicitly skipped. Authoring is Standard Schema | ||
| 302 | (StandardSchemaV1 & StandardJSONSchemaV1) with raw-JSON-Schema literals as a | ||
| 303 | typed first-class alternative — matches our rule. Effect beta.93→beta.59 | ||
| 304 | delta is small (ConstraintDecoder → one-line fix; hand-roll | ||
| 305 | StandardJSONSchemaV1 ~10 lines; reuse core sha256Hex). NOT portable as-is: | ||
| 306 | rework `storage-db.ts` (write a new adapter over our tables against the | ||
| 307 | ~90-line Storage port interface) and `engine.ts` (extract only ~700 LoC of | ||
| 308 | model-owned logic: computed-surface cache + requirement resolution + | ||
| 309 | runTool). Known rework gaps to fix at port time, not inherit: derive-time | ||
| 310 | execution is unsandboxed in-process (`code-executor.ts:23` records it) — our | ||
| 311 | port MUST run publish-time evaluation in the existing sandbox `collect` path | ||
| 312 | from day one; fan-in computed groups unsupported; OAuth discovery | ||
| 313 | unsupported. | ||
| 314 | |||
| 315 | **Semantic deltas — decided by Rhys before 3.1 specs:** | ||
| 316 | |||
| 317 | 1. **Handler credential contract (the deep one).** Current: handlers get an | ||
| 318 | opaque callable `AppIntegrationClient` proxy that re-enters | ||
| 319 | `ctx.execute` — app code never sees credentials, can only call other | ||
| 320 | executor tools. Rework (D49): engine resolves credentials once and hands | ||
| 321 | the handler **credential fields as data** (`ResolvedConnection.fields`, | ||
| 322 | e.g. access_token) for direct HTTP — that's what makes generators | ||
| 323 | possible (generated code does raw fetch with auth) and what | ||
| 324 | `defineProvider` exists for. Not either/or in principle (a tool-call | ||
| 325 | bridge can coexist as a second requirement kind later), but the ported | ||
| 326 | model's primary contract is fields-as-data per the rework. | ||
| 327 | 2. **Publish input.** Rework publish takes `SourceFiles` (push-based). | ||
| 328 | Decision: keep git/local-directory as _fetchers that produce | ||
| 329 | SourceFiles_ (preserves prod sources, the e2e suite, and the migration | ||
| 330 | path; git-client survives), with direct push publish added as the new | ||
| 331 | API. Delete-git-later stays open. | ||
| 332 | 3. **App identity** moves from caller-supplied slug to the `defineApp` | ||
| 333 | export inside the source (rework shape). | ||
| 334 | 4. **Versioning**: content hash of source IS the version identity; the | ||
| 335 | caller-supplied `sourceRef` + descriptor v6 die with the old model. | ||
| 336 | 5. **Ownership**: app/version rows carry `space` and NO owner — spaces | ||
| 337 | curate apps, principals own custody (connections stay on the legacy | ||
| 338 | owner model in this phase; the opaque-OwnerRef swap is Phase 4). | ||
| 339 | |||
| 340 | ### Phase 3 PR sequence (revised) | ||
| 341 | |||
| 342 | - **3.1** Port the model core as internal packages, no readers: authoring | ||
| 343 | SDK + runtime brands, publish pipeline (evaluation routed through the | ||
| 344 | sandbox `collect` seam), Storage port + adapter, greenfield tables | ||
| 345 | (`app`, `app_version`, `computed_surface` — space column, content-hash | ||
| 346 | identity, `current` pointer) in coreTables + cloud mirror + migration. | ||
| 347 | Ported rework tests come along (authoring-boundary, tool-groups, | ||
| 348 | publish). | ||
| 349 | - **3.2** Swap the apps plugin's internals onto the new model behind the | ||
| 350 | same costume: resolveTools/projectToolSchema/invokeTool read the new | ||
| 351 | tables; publish endpoint (`POST` source files); setCurrent/rollback + | ||
| 352 | versions listing routes; git/local-dir sync becomes fetch→publish. | ||
| 353 | Tool addresses and slug guards preserved; old collections | ||
| 354 | (`apps_descriptors`/`apps_tools`/`apps_sources`) still written by | ||
| 355 | NOTHING — one-off migration converts existing sources (re-fetch from | ||
| 356 | git), then the collections are dead. e2e: full existing custom-tools | ||
| 357 | suite green + new version-history/rollback scenario. | ||
| 358 | - **3.3** Console UI for the new model (sources → apps with versions, | ||
| 359 | publish status, rollback) replacing the ~1.3k LoC old client/panels. | ||
| 360 | - **3.4** Generators: fromOpenAPI + fromMCP + GeneratorClaim verification + | ||
| 361 | render endpoint, surfaced as the "add from spec/server" on-ramp. | ||
| 362 | (Vendors google/microsoft conversion ~1.15k LoC ports later with 0.2.) | ||
| 363 | |||
| 364 | ## Spaces: the rework's tenancy primitive (added Rhys, 2026-07-28) | ||
| 365 | |||
| 366 | The rework's SDK has exactly one tenancy concept: `SpaceId` | ||
| 367 | (`.reference/executor-rework/docs/TENANCY.md`, `docs/MODEL.md`). Adopt it as | ||
| 368 | the end-state tenancy model here. The shape: | ||
| 369 | |||
| 370 | - **Flat, no hierarchy, no default.** No parent, no inheritance, no | ||
| 371 | distinguished rows (rework D39/D44b). Sharing is an explicit act, never a | ||
| 372 | tree walk (R2 — re-ruled twice, do not relitigate). Every scoped row | ||
| 373 | carries a `space` column; every space-scoped route carries the space in | ||
| 374 | the URL path, never ambient context. | ||
| 375 | - **No org/project/team/user below the deployment edge.** The engine answers | ||
| 376 | "what is in this space"; the deployment answers "who is this and which | ||
| 377 | spaces may they touch" through named seams (`authorizeBrowserResource`, | ||
| 378 | `dashboardSpacesForPrincipal`, MCP space resolver). Membership is | ||
| 379 | authorized LIVE per request at the edge — projection may be cached, | ||
| 380 | authorization never. | ||
| 381 | - **Deployment mappings:** cloud = one WorkOS org mirrors lazily to one | ||
| 382 | space (mirror row `workosOrgId → space` is also the billing join — Autumn | ||
| 383 | keys on workosOrgId, never a space); self-host = one instance, one | ||
| 384 | found-or-created "Default" space, Better Auth org repurposed as the | ||
| 385 | instance membership table; local/desktop = one "local" space, one static | ||
| 386 | principal, single-tenancy declared via explicit sentinel, not defaulted. | ||
| 387 | - **Spaces curate, principals own.** Custody (secrets, connections' | ||
| 388 | secret-bearing halves) belongs to principals via opaque `OwnerRef` + | ||
| 389 | per-request `Reach` (R3); spaces curate apps and hold rows. Publishing an | ||
| 390 | app into a space IS its presence there — no installation rows, no | ||
| 391 | bindings; cross-space arrival (sharing/registry) is a future share-edge | ||
| 392 | row, not golden-path ceremony (D45/D42). | ||
| 393 | - Cross-org OAuth callbacks complete into the _initiating_ org's space; the | ||
| 394 | org header is a routing hint, not an authorization statement (R7). | ||
| 395 | |||
| 396 | **DECIDED (Rhys, 2026-07-28): spaces land in the CORE PRODUCT NOW — Phase S, | ||
| 397 | before the apps port.** Apps then publishes into real spaces from birth. | ||
| 398 | |||
| 399 | **Mapping onto this repo — the no-migration trick:** today's `tenant` column | ||
| 400 | is de facto the space — cloud writes the WorkOS org id there, self-host/local | ||
| 401 | write one constant. So: keep the persisted `tenant` column name (same rule as | ||
| 402 | `plugin_id`), and declare that **its value IS the space id**. Each org's | ||
| 403 | first space is grandfathered with `spaceId = <historical tenant value>` (the | ||
| 404 | org id); NEW spaces mint fresh ids written into the same column. The org→ | ||
| 405 | space mirror table records which space ids belong to which org (and carries | ||
| 406 | the billing join — Autumn keys on workosOrgId, never a space). Existing rows | ||
| 407 | never move; multi-space needs no schema change on scoped tables. "Space" is | ||
| 408 | contract vocabulary; "tenant" retreats to cloud-internal (see Vocabulary). | ||
| 409 | |||
| 410 | ### Phase S — spaces in core (before apps) | ||
| 411 | |||
| 412 | - **S1 — space table + boot ensure (no readers).** Greenfield `space` table | ||
| 413 | (id, name, created_at) in coreTables + cloud mirror + migration, plus the | ||
| 414 | cloud `org→space` mirror table. Boot/first-contact ensures the degenerate | ||
| 415 | space per deployment: cloud lazily on first authenticated org contact | ||
| 416 | (grandfathered id = org id), self-host "Default", local "local". Same | ||
| 417 | no-readers pattern as PR 1.1. | ||
| 418 | - **S2 — space threading.** `SpaceId` brand in the SDK; the scoped-executor | ||
| 419 | seam resolves request → space id (today: identical to tenant) and stamps | ||
| 420 | it; reads interpret `tenant` columns as space ids. No behavior change — | ||
| 421 | this is the retype + vocabulary PR. `reach: "tenant"` reads as | ||
| 422 | "space-wide reach" from here on. | ||
| 423 | - **S3 — multi-space on cloud + product surface.** Create/rename space, | ||
| 424 | space switcher in the console, membership = org membership (all spaces of | ||
| 425 | the org for now), org→N mirror rows, space-scoped catalog/connection | ||
| 426 | views (rows follow automatically — they're keyed by the space id in the | ||
| 427 | tenant column). Billing stays org-keyed via the mirror. e2e: two spaces | ||
| 428 | in one org, isolation of catalogs/connections between them, switcher. | ||
| 429 | - **S4 — sharing model on spaces.** Connect-time "share with space" writes | ||
| 430 | the space's shared owner-ref (replaces the org tier semantically; the | ||
| 431 | persisted `owner: "org"|"user"` column keeps its name, read-side | ||
| 432 | reinterpretation only). Edge computes reach as | ||
| 433 | [principal, sharedRef(space) for each member space]. This is the bridge | ||
| 434 | to Phase 4's full owner-set reach. | ||
| 435 | - **Later (not scheduled): local multi-space, file-backed.** Each local | ||
| 436 | space is its own store file (portable — copy/backup/sync/check in | ||
| 437 | alongside a repo); the daemon opens one storage handle per space and | ||
| 438 | routes by space id; the engine still sees flat SpaceIds. Custody never | ||
| 439 | enters the space file — secrets stay in the principal-keyed provider | ||
| 440 | (keychain), so a copied file carries connection metadata, never tokens. | ||
| 441 | - Phase 4 completes the picture: space-in-path routes, | ||
| 442 | `dashboardSpacesForPrincipal`-style seams, reach as an explicit owner | ||
| 443 | set. | ||
| 444 | |||
| 445 | Apps (Phase 3) then lands after S1/S2 — app rows carry a real `space` FK | ||
| 446 | from birth; S3/S4 can proceed in parallel with the apps port. | ||
| 447 | |||
| 448 | Interaction with shipped Phase 1 work: the subject table and platform view | ||
| 449 | are edge/deployment concepts (who exists in this identity world, what may | ||
| 450 | an operator read) — they sit above spaces and survive unchanged; `reach: | ||
| 451 | "tenant"` becomes "space-wide reach" vocabulary-wise at Phase 4. | ||
| 452 | |||
| 453 | ## Vocabulary: retire "tenant" from consumer-facing surfaces (Rhys, 2026-07-27) | ||
| 454 | |||
| 455 | Direction: the SDK should be agnostic to the consumer's data-model shape, the | ||
| 456 | way Composio's is — consumers pass an opaque user/owner id string and hang | ||
| 457 | whatever hierarchy they have (user, team, workspace) off it; the SDK never | ||
| 458 | models their org chart. The rework already designed this (`OwnerRef` opaque | ||
| 459 | brand, `Reach = {owners}`, deployments translate identity at the edge) — adopt | ||
| 460 | its vocabulary, not its schedule. "Tenant" remains a real _internal_ concept | ||
| 461 | (which Executor-cloud org owns the rows) but must not leak into surfaces | ||
| 462 | consumers touch. | ||
| 463 | |||
| 464 | Rules, effective immediately: | ||
| 465 | |||
| 466 | - Persisted column names (`tenant`, `subject`, `owner`) stay — renaming | ||
| 467 | storage is a cross-host migration for zero behavior (same rule as | ||
| 468 | `plugin_id`). | ||
| 469 | - Internal policy code (`reach: "tenant"`, `ExecutorOwnerPolicyContext`) may | ||
| 470 | keep the words. | ||
| 471 | - One contract noun: **`owner`** — the opaque ref naming who a connection | ||
| 472 | belongs to (end-user, team, workspace, or the org itself; the rework's | ||
| 473 | `OwnerRef`). "User" cannot be the contract noun because org-shared | ||
| 474 | connections are a load-bearing feature an end-user word can't cover. | ||
| 475 | - **"user" is presentation vocabulary only**, for surfaces whose rows genuinely | ||
| 476 | are the customer's end-users — the Phase 1.5 admin API says `users` because | ||
| 477 | the subject table is populated from authenticated principals. | ||
| 478 | - Hazard: the persisted `owner` column keeps its old meaning (the | ||
| 479 | `"org" | "user"` tier discriminator), while Phase 4 introduces the opaque | ||
| 480 | ref as a distinctly named type (`OwnerRef`). Same word, two meanings, | ||
| 481 | separated by layer — say so in policy-layer comments. | ||
| 482 | - The full swap (opaque owner refs at the SDK boundary, tenant retreating to a | ||
| 483 | cloud-internal detail) lands with the Phase 4 minimal SDK. | ||
| 484 | |||
| 485 | ## Phase 4 — minimal SDK + platform/product split | ||
| 486 | |||
| 487 | New consumer boundary modeled on the rework: `createExecutor` / | ||
| 488 | `createRemoteExecutor`, no Effect at the boundary, `tools.list/execute` + | ||
| 489 | connections + the new users/admin surface; policies/toolkits become optional | ||
| 490 | layers consumers opt into, not baked opinions (Composio-style minimalism). | ||
| 491 | |||
| 492 | Authorization end-state (Rhys, 2026-07-27): the engine enforces scope | ||
| 493 | mechanically but never decides policy — the host computes, per request, the | ||
| 494 | set of owners the request may read (the rework's `Reach = { owners }`), and | ||
| 495 | the storage predicate is just `row.owner ∈ reach.owners`. The 1.3 | ||
| 496 | `reach: "bound" | "tenant"` enum is a stepping stone and is replaced by this | ||
| 497 | set shape here: "bound" = a reach of one, "tenant" = all owners (wildcard so | ||
| 498 | cloud needn't enumerate), and in-between cases (team views, support sessions, | ||
| 499 | subset-restricted org keys) are just different sets. Writes stay hard-pinned | ||
| 500 | to a single owner; only read reach generalizes. Trust model becomes a | ||
| 501 | per-deployment choice: a trusted backend with a server key gets tenant-wide | ||
| 502 | reach and layers its own authorization (Composio-style); an end-user session | ||
| 503 | gets a reach of one. The enum-to-set swap is contained: the policy context is | ||
| 504 | constructed in exactly two places. | ||
| 505 | Start the platform/product package separation _during_ 2.3–2.6 (each PR moves | ||
| 506 | one thing to its final home) rather than as a big bang; the SDK boundary | ||
| 507 | itself lands last. | ||
| 508 | |||
| 509 | ## Bookkeeping | ||
| 510 | |||
| 511 | - Create `MISTAKES.md` / `DESIRES.md` / `LEARNINGS.md` (required by AGENTS.md, | ||
| 512 | currently missing) with the first entries from this migration. | ||
| 513 | - `apps/local/src/db/executor-schema.ts` is already stale vs `coreTables` | ||
| 514 | (missing several columns); its "keep in sync" comment is untrustworthy — | ||
| 515 | runtime schema comes from the ensure path, not this file. | ||