diff --git a/.changeset/sdk-no-private-api-leak.md b/.changeset/sdk-no-private-api-leak.md new file mode 100644 index 000000000..f0235d792 --- /dev/null +++ b/.changeset/sdk-no-private-api-leak.md @@ -0,0 +1,40 @@ +--- +"@executor-js/sdk": minor +"@executor-js/plugin-mcp": minor +"@executor-js/plugin-graphql": minor +"@executor-js/plugin-openapi": minor +"@executor-js/plugin-onepassword": minor +"@executor-js/plugin-google-discovery": minor +--- + +Stop the published plugin bundles from importing `@executor-js/api`. The +private server package was being pulled into every SDK chunk via +`import { InternalError } from "@executor-js/api"` (in each plugin's +group definition) and `import { addGroup, capture } from +"@executor-js/api"` (via the SDK's transitive import of its own +handlers). Because `@executor-js/api` is `private: true`, plain Node +ESM consumers hit `Cannot find package '@executor-js/api'` on +`import("@executor-js/plugin-mcp/core")` (and the same for graphql / +openapi). + +Fix: + +- `InternalError` (the wire-level 500 schema) moved to + `@executor-js/sdk/core`. `@executor-js/api` re-exports it for + back-compat, so server code is unaffected. +- The plugin SDK factories (`mcpPlugin`, `graphqlPlugin`, + `openApiPlugin`, `onepasswordPlugin`, `googleDiscoveryPlugin`) no + longer carry HTTP `routes` / `handlers` / `extensionService`. The + optional fields are layered on by a new HTTP-augmented variant + exposed from the `/api` subpath (`mcpHttpPlugin`, + `graphqlHttpPlugin`, `openApiHttpPlugin`, `onepasswordHttpPlugin`, + `googleDiscoveryHttpPlugin`). +- Hosts that mount plugin HTTP routes should switch their imports to + the `/api` subpath and the `*HttpPlugin` factory name. +- SDK-only consumers keep importing from the package root and no + longer transitively require `@executor-js/api`. + +Breaking for hosts that read `mcpPlugin(opts).routes` / +`.handlers` / `.extensionService` directly off the SDK factory's +return value — switch to the `*HttpPlugin` factory from +`@executor-js/plugin-*/api`. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7be3f88cf..c51c05ea2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,10 @@ jobs: # secret is configured. GITHUB_TOKEN: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }} + - name: Smoke test packed @executor-js library packages + if: steps.changesets.outputs.hasChangesets == 'false' + run: bun run release:smoke:packages + - name: Publish @executor-js library packages if: steps.changesets.outputs.hasChangesets == 'false' run: bun run release:publish:packages diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 3514c52d6..0f04149d9 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -1,7 +1,7 @@ import { defineExecutorConfig } from "@executor-js/sdk"; -import { openApiPlugin } from "@executor-js/plugin-openapi"; -import { mcpPlugin } from "@executor-js/plugin-mcp"; -import { graphqlPlugin } from "@executor-js/plugin-graphql"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; // --------------------------------------------------------------------------- @@ -39,11 +39,11 @@ export default defineExecutorConfig({ dialect: "pg", plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) => [ - openApiPlugin(), - mcpPlugin({ + openApiHttpPlugin(), + mcpHttpPlugin({ dangerouslyAllowStdioMCP: false, }), - graphqlPlugin(), + graphqlHttpPlugin(), workosVaultPlugin({ credentials: workosCredentials ?? { apiKey: "", clientId: "" }, ...(workosVaultClient ? { client: workosVaultClient } : {}), diff --git a/apps/local/executor.config.ts b/apps/local/executor.config.ts index 47999e4e2..201851a32 100644 --- a/apps/local/executor.config.ts +++ b/apps/local/executor.config.ts @@ -1,12 +1,12 @@ import { defineExecutorConfig } from "@executor-js/sdk"; import type { ConfigFileSink } from "@executor-js/config"; -import { openApiPlugin } from "@executor-js/plugin-openapi"; -import { mcpPlugin } from "@executor-js/plugin-mcp"; -import { googleDiscoveryPlugin } from "@executor-js/plugin-google-discovery"; -import { graphqlPlugin } from "@executor-js/plugin-graphql"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { googleDiscoveryHttpPlugin } from "@executor-js/plugin-google-discovery/api"; +import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { keychainPlugin } from "@executor-js/plugin-keychain"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; -import { onepasswordPlugin } from "@executor-js/plugin-onepassword"; +import { onepasswordHttpPlugin } from "@executor-js/plugin-onepassword/api"; // --------------------------------------------------------------------------- // Single source of truth for the local app's plugin list. @@ -27,12 +27,12 @@ export default defineExecutorConfig({ dialect: "sqlite", plugins: ({ configFile }: LocalPluginDeps = {}) => [ - openApiPlugin({ configFile }), - mcpPlugin({ dangerouslyAllowStdioMCP: true, configFile }), - googleDiscoveryPlugin(), - graphqlPlugin({ configFile }), + openApiHttpPlugin({ configFile }), + mcpHttpPlugin({ dangerouslyAllowStdioMCP: true, configFile }), + googleDiscoveryHttpPlugin(), + graphqlHttpPlugin({ configFile }), keychainPlugin(), fileSecretsPlugin(), - onepasswordPlugin(), + onepasswordHttpPlugin(), ] as const, }); diff --git a/apps/marketing/src/pages/api/detect.ts b/apps/marketing/src/pages/api/detect.ts index f6b9d2178..ec4be04a9 100644 --- a/apps/marketing/src/pages/api/detect.ts +++ b/apps/marketing/src/pages/api/detect.ts @@ -1,9 +1,9 @@ import type { APIRoute } from "astro"; import { Effect } from "effect"; import { createExecutor, makeTestConfig, type Tool } from "@executor-js/sdk"; -import { openApiPlugin } from "@executor-js/plugin-openapi"; -import { graphqlPlugin } from "@executor-js/plugin-graphql"; -import { googleDiscoveryPlugin } from "@executor-js/plugin-google-discovery"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; +import { googleDiscoveryHttpPlugin } from "@executor-js/plugin-google-discovery/api"; export const prerender = false; @@ -68,7 +68,7 @@ export const POST: APIRoute = async ({ request }) => { const program = Effect.gen(function* () { const config = makeTestConfig({ - plugins: [openApiPlugin(), graphqlPlugin(), googleDiscoveryPlugin()], + plugins: [openApiHttpPlugin(), graphqlHttpPlugin(), googleDiscoveryHttpPlugin()], }); const executor = yield* createExecutor(config); diff --git a/bun.lock b/bun.lock index eb77f14b9..44a34a807 100644 --- a/bun.lock +++ b/bun.lock @@ -579,13 +579,13 @@ "name": "@executor-js/plugin-google-discovery", "version": "0.1.0", "dependencies": { - "@executor-js/api": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", }, "devDependencies": { "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", @@ -596,12 +596,14 @@ }, "peerDependencies": { "@effect/atom-react": "catalog:", + "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", "@tanstack/react-router": "catalog:", "react": "catalog:", }, "optionalPeers": [ "@effect/atom-react", + "@executor-js/api", "@executor-js/react", "@tanstack/react-router", "react", @@ -708,12 +710,12 @@ "@1password/op-js": "^0.1.13", "@1password/sdk": "^0.4.1-beta.1", "@effect/atom-react": "catalog:", - "@executor-js/api": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", }, "devDependencies": { "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", @@ -723,11 +725,13 @@ }, "peerDependencies": { "@effect/atom-react": "catalog:", + "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", "react": ">=18", }, "optionalPeers": [ "@effect/atom-react", + "@executor-js/api", "@executor-js/react", "react", ], diff --git a/package.json b/package.json index 25213cec4..a3b7dee90 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "release:publish:packages": "bun run scripts/publish-packages.ts", "release:publish:packages:dry-run": "bun run scripts/publish-packages.ts --dry-run", "release:publish:packages:prepare": "bun run scripts/publish-packages.ts --prepare-only", + "release:smoke:packages": "bun run scripts/smoke-test-packed.ts", "clean": "bun run scripts/clean.ts", "prepare": "effect-language-service patch && effect-tsgo patch" }, diff --git a/packages/core/api/src/observability.ts b/packages/core/api/src/observability.ts index e8cd6541d..e49eb10ca 100644 --- a/packages/core/api/src/observability.ts +++ b/packages/core/api/src/observability.ts @@ -34,16 +34,13 @@ import { Cause, Context, Effect, Layer, Option, Result, Schema } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; import { HttpApiMiddleware, type HttpApi, type HttpApiGroup } from "effect/unstable/httpapi"; import type { StorageFailure } from "@executor-js/storage-core"; +import { InternalError } from "@executor-js/sdk/core"; -/** Public 500 surface. Opaque by schema. */ -export class InternalError extends Schema.TaggedErrorClass()( - "InternalError", - { - /** Opaque correlation id for backend lookup (Sentry event id, log line, etc.). */ - traceId: Schema.String, - }, - { httpApiStatus: 500 }, -) {} +// Re-export so existing `@executor-js/api` consumers keep working. +// The schema lives in the SDK so plugin `HttpApiGroup` definitions can +// reference it without dragging this server-only package into their +// SDK chunks. +export { InternalError }; export interface ErrorCaptureShape { /** diff --git a/packages/core/sdk/src/api-errors.ts b/packages/core/sdk/src/api-errors.ts new file mode 100644 index 000000000..9532c38b8 --- /dev/null +++ b/packages/core/sdk/src/api-errors.ts @@ -0,0 +1,20 @@ +// --------------------------------------------------------------------------- +// Wire-level HTTP errors. Lives in the SDK so plugin `HttpApiGroup` +// definitions (which sit on the SDK side of the dependency graph and +// must stay publishable) can declare them without dragging in the +// server-only `@executor-js/api` package. The HTTP edge in +// `@executor-js/api` re-exports these and pairs them with the +// translation/capture helpers used by handlers. +// --------------------------------------------------------------------------- + +import { Schema } from "effect"; + +/** Public 500 surface. Opaque by schema — only `traceId` crosses the wire. */ +export class InternalError extends Schema.TaggedErrorClass()( + "InternalError", + { + /** Opaque correlation id for backend lookup (Sentry event id, log line, etc.). */ + traceId: Schema.String, + }, + { httpApiStatus: 500 }, +) {} diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 3e32a1210..1b264853f 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -321,3 +321,6 @@ export { type TypeScriptRenderOptions, type TypeScriptSchemaPreview, } from "./schema-types"; + +// Wire-level HTTP error schemas usable by plugin HttpApiGroup definitions. +export { InternalError } from "./api-errors"; diff --git a/packages/plugins/google-discovery/package.json b/packages/plugins/google-discovery/package.json index ba3a2fb9f..5e6045d66 100644 --- a/packages/plugins/google-discovery/package.json +++ b/packages/plugins/google-discovery/package.json @@ -54,13 +54,13 @@ "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@executor-js/api": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:" }, "devDependencies": { "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", @@ -71,6 +71,7 @@ }, "peerDependencies": { "@effect/atom-react": "catalog:", + "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", "@tanstack/react-router": "catalog:", "react": "catalog:" @@ -85,6 +86,9 @@ "@tanstack/react-router": { "optional": true }, + "@executor-js/api": { + "optional": true + }, "@executor-js/react": { "optional": true } diff --git a/packages/plugins/google-discovery/src/api/group.ts b/packages/plugins/google-discovery/src/api/group.ts index 7f823bfee..99ca7a2a0 100644 --- a/packages/plugins/google-discovery/src/api/group.ts +++ b/packages/plugins/google-discovery/src/api/group.ts @@ -1,7 +1,6 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { ScopeId, SecretBackedValue } from "@executor-js/sdk/core"; -import { InternalError } from "@executor-js/api"; +import { InternalError, ScopeId, SecretBackedValue } from "@executor-js/sdk/core"; import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "../sdk/errors"; import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source"; diff --git a/packages/plugins/google-discovery/src/api/index.ts b/packages/plugins/google-discovery/src/api/index.ts index c16b06468..c1c56fc14 100644 --- a/packages/plugins/google-discovery/src/api/index.ts +++ b/packages/plugins/google-discovery/src/api/index.ts @@ -1,2 +1,23 @@ +import { definePlugin } from "@executor-js/sdk/core"; + +import { googleDiscoveryPlugin } from "../sdk/plugin"; +import { GoogleDiscoveryGroup } from "./group"; +import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers"; + export { GoogleDiscoveryGroup } from "./group"; export { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers"; + +// HTTP-augmented variant of `googleDiscoveryPlugin`. The returned +// plugin carries the HTTP `routes`, `handlers`, and `extensionService` +// so a host can mount the Google Discovery HTTP surface. Hosts that +// compose an `HttpApi` should import this. SDK-only consumers stay on +// `@executor-js/plugin-google-discovery` and never load +// `@executor-js/api`. +export const googleDiscoveryHttpPlugin = definePlugin( + (options?: Parameters[0]) => ({ + ...googleDiscoveryPlugin(options), + routes: () => GoogleDiscoveryGroup, + handlers: () => GoogleDiscoveryHandlers, + extensionService: GoogleDiscoveryExtensionService, + }), +); diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts index 28cce1ebf..095875097 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -11,9 +11,6 @@ import { type ToolAnnotations, } from "@executor-js/sdk/core"; -import { GoogleDiscoveryGroup } from "../api/group"; -import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "../api/handlers"; - import { googleDiscoverySchema, makeGoogleDiscoveryStore, @@ -546,7 +543,8 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ // the `authorization-code` strategy's tokenEndpoint), so refresh // reaches Google through the unified code path. - routes: () => GoogleDiscoveryGroup, - handlers: () => GoogleDiscoveryHandlers, - extensionService: GoogleDiscoveryExtensionService, + // HTTP transport (routes/handlers/extensionService) is layered on by + // the api-aware factory in `@executor-js/plugin-google-discovery/api`. + // Hosts that want the HTTP surface import the plugin from there; + // SDK-only consumers stay on this entry and avoid the server-only deps. })); diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 1e6193a26..8c60060ed 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -1,7 +1,6 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { ScopeId } from "@executor-js/sdk/core"; -import { InternalError } from "@executor-js/api"; +import { InternalError, ScopeId } from "@executor-js/sdk/core"; import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; import { diff --git a/packages/plugins/graphql/src/api/index.ts b/packages/plugins/graphql/src/api/index.ts index a2edf7771..3558316e5 100644 --- a/packages/plugins/graphql/src/api/index.ts +++ b/packages/plugins/graphql/src/api/index.ts @@ -1,2 +1,20 @@ +import { definePlugin } from "@executor-js/sdk/core"; + +import { graphqlPlugin, type GraphqlPluginOptions } from "../sdk/plugin"; +import { GraphqlGroup } from "./group"; +import { GraphqlHandlers, GraphqlExtensionService } from "./handlers"; + export { GraphqlGroup } from "./group"; export { GraphqlHandlers, GraphqlExtensionService } from "./handlers"; + +// HTTP-augmented variant of `graphqlPlugin`. The returned plugin +// carries the HTTP `routes`, `handlers`, and `extensionService` so a +// host can mount the GraphQL HTTP surface. Hosts that compose an +// `HttpApi` should import this. SDK-only consumers stay on +// `@executor-js/plugin-graphql` and never load `@executor-js/api`. +export const graphqlHttpPlugin = definePlugin((options?: GraphqlPluginOptions) => ({ + ...graphqlPlugin(options), + routes: () => GraphqlGroup, + handlers: () => GraphqlHandlers, + extensionService: GraphqlExtensionService, +})); diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 47d09af02..691bf4b02 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -2,9 +2,6 @@ import { Effect, Option, Schema } from "effect"; import type { Layer } from "effect"; import { HttpClient } from "effect/unstable/http"; -import { GraphqlGroup } from "../api/group"; -import { GraphqlExtensionService, GraphqlHandlers } from "../api/handlers"; - import { ConnectionId, ConfiguredCredentialBinding, @@ -1183,9 +1180,9 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { return null; }), - - routes: () => GraphqlGroup, - handlers: () => GraphqlHandlers, - extensionService: GraphqlExtensionService, }; + // HTTP transport (routes/handlers/extensionService) is layered on by + // the api-aware factory in `@executor-js/plugin-graphql/api`. Hosts that + // want the HTTP surface import the plugin from there; SDK-only + // consumers stay on this entry and avoid the server-only deps. }); diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index ab4d5e471..43dbd9127 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -1,7 +1,6 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { ScopeId, SecretBackedMap } from "@executor-js/sdk/core"; -import { InternalError } from "@executor-js/api"; +import { InternalError, ScopeId, SecretBackedMap } from "@executor-js/sdk/core"; import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; import { McpStoredSourceSchema } from "../sdk/stored-source"; diff --git a/packages/plugins/mcp/src/api/index.ts b/packages/plugins/mcp/src/api/index.ts index f56985c8b..d07370f1c 100644 --- a/packages/plugins/mcp/src/api/index.ts +++ b/packages/plugins/mcp/src/api/index.ts @@ -1,2 +1,20 @@ +import { definePlugin } from "@executor-js/sdk/core"; + +import { mcpPlugin, type McpPluginOptions } from "../sdk/plugin"; +import { McpGroup } from "./group"; +import { McpHandlers, McpExtensionService } from "./handlers"; + export { McpGroup } from "./group"; export { McpHandlers, McpExtensionService } from "./handlers"; + +// HTTP-augmented variant of `mcpPlugin`. The returned plugin carries +// the HTTP `routes`, `handlers`, and `extensionService` so a host can +// mount the MCP HTTP surface. Hosts that compose an `HttpApi` should +// import this. SDK-only consumers stay on `@executor-js/plugin-mcp` +// and never load `@executor-js/api`. +export const mcpHttpPlugin = definePlugin((options?: McpPluginOptions) => ({ + ...mcpPlugin(options), + routes: () => McpGroup, + handlers: () => McpHandlers, + extensionService: McpExtensionService, +})); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 07ba4d1f0..cc963a9f6 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -13,9 +13,6 @@ import type { HttpClient } from "effect/unstable/http"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; -import { McpGroup } from "../api/group"; -import { McpExtensionService, McpHandlers } from "../api/handlers"; - import { ConfiguredCredentialBinding, ConnectionId, @@ -1801,14 +1798,11 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { runtimeRef.current = null; } }).pipe(Effect.withSpan("mcp.plugin.close")), - - // HTTP transport. `McpHandlers` requires `McpExtensionService`; the - // host satisfies it via the `extensionService` Tag — at boot for - // local, per request for cloud. - routes: () => McpGroup, - handlers: () => McpHandlers, - extensionService: McpExtensionService, }; + // HTTP transport (routes/handlers/extensionService) is layered on by + // the api-aware factory in `@executor-js/plugin-mcp/api`. Hosts that + // want the HTTP surface import the plugin from there; SDK-only + // consumers stay on this entry and avoid the server-only deps. }); // --------------------------------------------------------------------------- diff --git a/packages/plugins/onepassword/package.json b/packages/plugins/onepassword/package.json index b1adbc77c..51aafb7ae 100644 --- a/packages/plugins/onepassword/package.json +++ b/packages/plugins/onepassword/package.json @@ -56,12 +56,12 @@ "@1password/op-js": "^0.1.13", "@1password/sdk": "^0.4.1-beta.1", "@effect/atom-react": "catalog:", - "@executor-js/api": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:" }, "devDependencies": { "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", @@ -71,6 +71,7 @@ }, "peerDependencies": { "@effect/atom-react": "catalog:", + "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", "react": ">=18" }, @@ -81,6 +82,9 @@ "@effect/atom-react": { "optional": true }, + "@executor-js/api": { + "optional": true + }, "@executor-js/react": { "optional": true } diff --git a/packages/plugins/onepassword/src/api/group.ts b/packages/plugins/onepassword/src/api/group.ts index 72343fd44..347c66f50 100644 --- a/packages/plugins/onepassword/src/api/group.ts +++ b/packages/plugins/onepassword/src/api/group.ts @@ -1,7 +1,6 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { ScopeId } from "@executor-js/sdk/core"; -import { InternalError } from "@executor-js/api"; +import { InternalError, ScopeId } from "@executor-js/sdk/core"; import { OnePasswordError } from "../sdk/errors"; import { OnePasswordConfig, OnePasswordConfigSchema, Vault, ConnectionStatus } from "../sdk/types"; diff --git a/packages/plugins/onepassword/src/api/index.ts b/packages/plugins/onepassword/src/api/index.ts index bbca59bcd..277690bdc 100644 --- a/packages/plugins/onepassword/src/api/index.ts +++ b/packages/plugins/onepassword/src/api/index.ts @@ -1,2 +1,20 @@ +import { definePlugin } from "@executor-js/sdk/core"; + +import { onepasswordPlugin, type OnePasswordPluginOptions } from "../sdk/plugin"; +import { OnePasswordGroup } from "./group"; +import { OnePasswordHandlers, OnePasswordExtensionService } from "./handlers"; + export { OnePasswordGroup } from "./group"; export { OnePasswordHandlers, OnePasswordExtensionService } from "./handlers"; + +// HTTP-augmented variant of `onepasswordPlugin`. The returned plugin +// carries the HTTP `routes`, `handlers`, and `extensionService` so a +// host can mount the 1Password HTTP surface. Hosts that compose an +// `HttpApi` should import this. SDK-only consumers stay on +// `@executor-js/plugin-onepassword` and never load `@executor-js/api`. +export const onepasswordHttpPlugin = definePlugin((options?: OnePasswordPluginOptions) => ({ + ...onepasswordPlugin(options), + routes: () => OnePasswordGroup, + handlers: () => OnePasswordHandlers, + extensionService: OnePasswordExtensionService, +})); diff --git a/packages/plugins/onepassword/src/sdk/plugin.ts b/packages/plugins/onepassword/src/sdk/plugin.ts index fd1f3ce11..5733bedb7 100644 --- a/packages/plugins/onepassword/src/sdk/plugin.ts +++ b/packages/plugins/onepassword/src/sdk/plugin.ts @@ -9,9 +9,6 @@ import { type StorageFailure, } from "@executor-js/sdk/core"; -import { OnePasswordGroup } from "../api/group"; -import { OnePasswordExtensionService, OnePasswordHandlers } from "../api/handlers"; - import { OnePasswordConfig, Vault, ConnectionStatus } from "./types"; import type { OnePasswordAuth } from "./types"; import { OnePasswordError } from "./errors"; @@ -308,9 +305,9 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption extension: (ctx) => makeOnePasswordExtension(ctx, timeoutMs, preferSdk), secretProviders: (ctx) => [makeProvider(ctx, timeoutMs, preferSdk)], - - routes: () => OnePasswordGroup, - handlers: () => OnePasswordHandlers, - extensionService: OnePasswordExtensionService, }; + // HTTP transport (routes/handlers/extensionService) is layered on by + // the api-aware factory in `@executor-js/plugin-onepassword/api`. Hosts + // that want the HTTP surface import the plugin from there; SDK-only + // consumers stay on this entry and avoid the server-only deps. }); diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 8a40f5912..3d97d9929 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -1,7 +1,11 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { ScopeId, ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk/core"; -import { InternalError } from "@executor-js/api"; +import { + InternalError, + ScopeId, + ScopedSecretCredentialInput, + SecretBackedValue, +} from "@executor-js/sdk/core"; import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; import { SpecPreview } from "../sdk/preview"; diff --git a/packages/plugins/openapi/src/api/index.ts b/packages/plugins/openapi/src/api/index.ts index bf34833cd..6212e9ab2 100644 --- a/packages/plugins/openapi/src/api/index.ts +++ b/packages/plugins/openapi/src/api/index.ts @@ -1,2 +1,20 @@ +import { definePlugin } from "@executor-js/sdk/core"; + +import { openApiPlugin, type OpenApiPluginOptions } from "../sdk/plugin"; +import { OpenApiGroup } from "./group"; +import { OpenApiHandlers, OpenApiExtensionService } from "./handlers"; + export { OpenApiGroup } from "./group"; export { OpenApiHandlers, OpenApiExtensionService } from "./handlers"; + +// HTTP-augmented variant of `openApiPlugin`. The returned plugin +// carries the HTTP `routes`, `handlers`, and `extensionService` so a +// host can mount the OpenAPI HTTP surface. Hosts that compose an +// `HttpApi` should import this. SDK-only consumers stay on +// `@executor-js/plugin-openapi` and never load `@executor-js/api`. +export const openApiHttpPlugin = definePlugin((options?: OpenApiPluginOptions) => ({ + ...openApiPlugin(options), + routes: () => OpenApiGroup, + handlers: () => OpenApiHandlers, + extensionService: OpenApiExtensionService, +})); diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 5f55bb429..e3c3de205 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -2,9 +2,6 @@ import { Effect, Option, Predicate, Schema } from "effect"; import type { Layer } from "effect"; import { HttpClient } from "effect/unstable/http"; -import { OpenApiGroup } from "../api/group"; -import { OpenApiExtensionService, OpenApiHandlers } from "../api/handlers"; - import { ScopeId, SecretId, @@ -1407,14 +1404,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { namespace, }); }), - - // HTTP transport. `OpenApiHandlers` is the existing late-binding - // Layer that requires `OpenApiExtensionService`; the host satisfies - // it via the spec's `extensionService` Tag — at boot for local - // (`composePluginHandlers(plugins, executor)`), per-request for - // cloud (`providePluginExtensions(plugins)(executor)`). - routes: () => OpenApiGroup, - handlers: () => OpenApiHandlers, - extensionService: OpenApiExtensionService, }; + // HTTP transport (routes/handlers/extensionService) is layered on by + // the api-aware factory in `@executor-js/plugin-openapi/api`. Hosts that + // want the HTTP surface import the plugin from there; SDK-only + // consumers stay on this entry and avoid the server-only deps. }); diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts index 0511e654b..f12453e32 100644 --- a/scripts/publish-packages.ts +++ b/scripts/publish-packages.ts @@ -287,12 +287,7 @@ const publishPackage = async ( throw new Error(`Missing dist/ in ${pkgDir}. Did you run 'bun run build:packages'?`); } - if (await packageAlreadyPublished(name, version)) { - console.log(`[skip] ${name}@${version} already on npm`); - return; - } - - console.log(`[publish] ${name}@${version} (${channel})${dryRun ? " [dry-run]" : ""}`); + console.log(`[pack] ${name}@${version} (${channel})${dryRun ? " [dry-run]" : ""}`); // Clean any stale tarballs from previous runs so our readdir finds exactly // the archive produced by the pack below. @@ -326,6 +321,15 @@ const publishPackage = async ( return; } + // Skip publishing already-shipped versions. The pack still ran above so + // smoke tests / pkg-pr-new previews always have a fresh tarball. + if (await packageAlreadyPublished(name, version)) { + console.log(`[skip] ${name}@${version} already on npm`); + return; + } + + console.log(`[publish] ${name}@${version} (${channel})`); + const args = ["publish", tarball, "--access", "public", "--tag", channel]; if (process.env.GITHUB_ACTIONS === "true") { args.push("--provenance"); diff --git a/scripts/smoke-test-packed.ts b/scripts/smoke-test-packed.ts new file mode 100644 index 000000000..c7290288e --- /dev/null +++ b/scripts/smoke-test-packed.ts @@ -0,0 +1,234 @@ +#!/usr/bin/env bun +/** + * Pack-and-import smoke test for the public `@executor-js/*` packages. + * + * Reproduces what an external npm consumer experiences: + * + * 1. Pack each publishable workspace via `publish-packages.ts + * --dry-run`, so `publishConfig.exports` and `workspace:*` rewrites + * are applied to the tarball. + * 2. For each package, install the tarball into a fresh temp dir + * with npm `overrides` pointing every other `@executor-js/*` + * transitive dep at its local tarball — otherwise npm pulls the + * currently-published version from the registry, which masks any + * internal-API mismatch this branch introduced. + * 3. Read the installed package.json (the post-`publishConfig` view) + * and dynamically `import()` every subpath in its `exports` map. + * + * Failures for `@executor-js/*` package not-found are hard failures — + * that's the regression class where private workspace packages leak + * into a public bundle. Failures for other peers (`react`, `effect`, + * `@tanstack/*`, etc.) are downgraded to warnings, since they reflect + * a missing peer in the smoke environment, not a bug in the bundle. + * + * Invoke via `bun run release:smoke:packages`. + */ +import { $ } from "bun"; +import { existsSync, readdirSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const PUBLIC_PACKAGE_DIRS = [ + "packages/core/storage-core", + "packages/kernel/core", + "packages/kernel/runtime-quickjs", + "packages/core/sdk", + "packages/core/config", + "packages/core/execution", + "packages/core/cli", + "packages/plugins/example", + "packages/plugins/file-secrets", + "packages/plugins/google-discovery", + "packages/plugins/graphql", + "packages/plugins/keychain", + "packages/plugins/mcp", + "packages/plugins/onepassword", + "packages/plugins/openapi", +] as const; + +type PackageJson = { + name: string; + version: string; + exports?: Record; +}; + +const readPackageJson = async (pkgDir: string): Promise => { + const raw = await readFile(join(pkgDir, "package.json"), "utf8"); + return JSON.parse(raw) as PackageJson; +}; + +const findTarball = (pkgDir: string): string | null => { + const tgz = readdirSync(pkgDir).find((entry) => entry.endsWith(".tgz")); + return tgz ? join(pkgDir, tgz) : null; +}; + +const subpathsToTest = (pkg: PackageJson): readonly string[] => Object.keys(pkg.exports ?? {}); + +const importSpecifier = (pkgName: string, subpath: string): string => + subpath === "." ? pkgName : `${pkgName}${subpath.slice(1)}`; + +type SmokeFailure = { + readonly pkg: string; + readonly subpath: string; + readonly reason: string; +}; + +const PRIVATE_PACKAGE_RE = /Cannot find package '(@executor-js\/[^']+)'/; + +const firstMeaningfulLine = (stderr: string): string => { + const lines = stderr + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const errorLine = lines.find((line) => /^(\w*Error|Cannot find)/i.test(line)); + if (errorLine) return errorLine; + for (const line of lines) { + if (line.startsWith("node:")) continue; + if (line.startsWith("file://")) continue; + if (line.startsWith("at ")) continue; + if (line.startsWith("import ")) continue; + if (line.startsWith("throw ")) continue; + if (line === "^" || /^\^+$/.test(line)) continue; + return line; + } + return lines[0] ?? "(no stderr)"; +}; + +type Tarballs = ReadonlyMap; + +const smokeTestPackage = async ( + pkgDir: string, + tarballs: Tarballs, + failures: SmokeFailure[], +): Promise => { + const pkg = await readPackageJson(pkgDir); + const tarballPath = tarballs.get(pkg.name); + if (!tarballPath) { + failures.push({ pkg: pkg.name, subpath: "", reason: "no tarball produced" }); + return; + } + + const tmp = await mkdtemp(join(tmpdir(), "executor-smoke-")); + try { + // npm `overrides` forces transitive `@executor-js/*` deps to resolve + // to their local tarball instead of whatever's published on npm. + // Without this, a plugin built against an unreleased symbol in + // `@executor-js/sdk` would silently install the published `sdk` + // and fail at import — a real bug, but not the bundle bug we're + // here to catch. + const overrides: Record = {}; + for (const [name, path] of tarballs) { + overrides[name] = `file:${path}`; + } + const fixture = { + name: "executor-smoke-fixture", + version: "0.0.0", + private: true, + type: "module", + dependencies: { [pkg.name]: `file:${tarballPath}` }, + overrides, + }; + await writeFile(join(tmp, "package.json"), `${JSON.stringify(fixture, null, 2)}\n`); + + const install = await $`npm install --no-audit --no-fund --legacy-peer-deps` + .cwd(tmp) + .quiet() + .nothrow(); + if (install.exitCode !== 0) { + failures.push({ + pkg: pkg.name, + subpath: "", + reason: install.stderr.toString().trim().split("\n").slice(-3).join("\n"), + }); + return; + } + + // Read the installed manifest — that's the real published view + // (publishConfig.exports applied, workspace specifiers resolved). + const installedPkg = await readPackageJson(join(tmp, "node_modules", ...pkg.name.split("/"))); + const subpaths = subpathsToTest(installedPkg); + if (subpaths.length === 0) { + failures.push({ + pkg: pkg.name, + subpath: "", + reason: "no exports declared in published manifest", + }); + return; + } + + for (const subpath of subpaths) { + const spec = importSpecifier(pkg.name, subpath); + const probe = + await $`node --input-type=module --eval ${`await import(${JSON.stringify(spec)});`}` + .cwd(tmp) + .quiet() + .nothrow(); + if (probe.exitCode === 0) { + console.log(` ok ${spec}`); + continue; + } + const stderr = probe.stderr.toString(); + const privateMatch = stderr.match(PRIVATE_PACKAGE_RE); + if (privateMatch) { + const offending = privateMatch[1]; + failures.push({ + pkg: pkg.name, + subpath, + reason: `published bundle imports private workspace package '${offending}'`, + }); + console.log(` FAIL ${spec} — references private '${offending}'`); + continue; + } + const peerMatch = + stderr.match(/Cannot find package '([^']+)'/) ?? + stderr.match(/Cannot find module '([^']+)'/); + const detail = peerMatch ? `missing peer '${peerMatch[1]}'` : firstMeaningfulLine(stderr); + console.log(` skip ${spec} — ${detail}`); + } + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}; + +const main = async () => { + console.log("[smoke] packing public packages via publish-packages.ts --dry-run"); + await $`bun run scripts/publish-packages.ts --dry-run`.cwd(repoRoot); + + const tarballs = new Map(); + for (const relDir of PUBLIC_PACKAGE_DIRS) { + const pkgDir = join(repoRoot, relDir); + if (!existsSync(pkgDir)) continue; + const pkg = await readPackageJson(pkgDir); + const tarball = findTarball(pkgDir); + if (tarball) tarballs.set(pkg.name, tarball); + } + + const failures: SmokeFailure[] = []; + for (const relDir of PUBLIC_PACKAGE_DIRS) { + const pkgDir = join(repoRoot, relDir); + if (!existsSync(pkgDir)) { + failures.push({ pkg: relDir, subpath: "", reason: "missing dir" }); + continue; + } + const pkg = await readPackageJson(pkgDir); + console.log(`[smoke] ${pkg.name}`); + await smokeTestPackage(pkgDir, tarballs, failures); + } + + if (failures.length === 0) { + console.log("[smoke] all packages OK"); + return; + } + + console.error(`\n[smoke] ${failures.length} failure(s):`); + for (const f of failures) { + console.error(` - ${f.pkg}${f.subpath ? ` (${f.subpath})` : ""}: ${f.reason}`); + } + process.exit(1); +}; + +await main();