Enforce shared, client, and server plugin SDK boundaries (#4347)

* fix(plugins): keep React out of the server plugin host

* fix(plugins): align SDK guidance and tests with runtime boundaries

* fix(plugin): loosen the react peer to ~19.1.0

The plugin SDK pins its react peer to exactly 19.1.0, and that pin is
what a downstream `npm i -g @getpaseo/cli` resolves against. So every
user ends up with react 19.1.0 in their global node_modules, where
corporate dependency scanners flag it: the CVE-2025-55182 advisory range
covers 19.1.0 even though the vulnerability itself lives in
react-server-dom-webpack/-parcel/-turbopack, none of which the CLI
installs. The user is then left hand-patching node_modules or
uninstalling Paseo.

Widening the peer to ~19.1.0 lets a downstream install pick the latest
19.1.x patch (19.1.9 today) while this repo stays on 19.1.0, which the
root override still pins.

Keeping the repo itself on 19.1.0 is deliberate. react-native 0.81.5
embeds a 19.1.0 Paper renderer that hard-throws "Incompatible React
versions" on any exact mismatch, and it is reachable from Fabric builds
too: RendererImplementation.js requires the Paper shim unconditionally
in findNodeHandle, unstable_batchedUpdates, sendAccessibilityEvent and
three other entry points, and react-native-gesture-handler re-exports
findNodeHandle. Bumping the app's react is therefore not safe until
react-native moves.

The published CLI tree contains no react-native, so downstream installs
are not subject to that constraint.

Supersedes the earlier 19.1.5/19.1.9 version-bump approach on this
branch, which would have broken the mobile app.

* Enforce example import ownership including client types

* Update Nix dependency hash for plugin peer metadata

* Group plugin APIs under client and server entry trees

* Resolve plugin type dependencies through declarations

* Validate declarations for implicit plugin type imports

* Preserve optional imports and validate declaration references

* Refresh Nix hash for plugin declaration resolver

* Canonicalize plugin paths consistently on Windows

* Normalize plugin ownership paths across resolver boundaries

---------

Co-authored-by: liujin0506 <liujin0506@qq.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Rafał Lindemann
2026-09-07 23:42:28 +02:00
committed by GitHub
co-authored by liujin0506 Mohamed Boudra
parent d779514f10
commit 1f5b6143d6
120 changed files with 1774 additions and 929 deletions
+1
View File
@@ -40,6 +40,7 @@ This is the section I read most carefully. I need to see that *you* tested this,
### Checklist
- [ ] Plugin changes follow the [SDK import boundaries](../docs/plugins.md#sdk-import-boundaries) (if applicable)
- [ ] One focused change
- [ ] `npm run typecheck` passes
- [ ] `npm run lint` passes
+4 -1
View File
@@ -50,6 +50,9 @@ app:
- "packages/expo-two-way-audio/**"
sdk:
- "packages/plugin/**"
- "plugin-examples/**"
- "public-docs/plugins/v0.8/**"
- "packages/client/**"
- "packages/highlight/**"
- "packages/protocol/**"
@@ -66,7 +69,7 @@ browser:
- "packages/server/src/server/agent/plugin-provider.ts"
- "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts"
- "packages/server/src/executable-resolution/**"
- "packages/plugin/src/provider.ts"
- "packages/plugin/src/server/provider.ts"
- "packages/app/src/!(desktop)/**"
- "packages/app/e2e/browser/**"
- "packages/app/e2e/support/**"
+9
View File
@@ -298,6 +298,15 @@ jobs:
- name: Build client dependencies
run: npm run build:client
- name: Build plugin SDK
run: npm run build:plugin
- name: Run plugin SDK tests
run: npm run test --workspace=@getpaseo/plugin
- name: Typecheck plugin examples
run: npm run typecheck --workspace=@getpaseo/plugin
- name: Run protocol tests
run: npm run test --workspace=@getpaseo/protocol
+2
View File
@@ -108,6 +108,8 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
## Critical rules
- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries.
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
+2
View File
@@ -38,6 +38,8 @@ Due to the volume and my limited bandwidth, I may not participate in all the dis
## Pull requests
For plugin changes, start with the [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).
Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed.
Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at.
+38 -17
View File
@@ -158,7 +158,7 @@ Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
@@ -167,16 +167,37 @@ Plugin UI runs on desktop and mobile across multiple themes: color every `Text`
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
See `public-docs/plugins/v0.8/reference.md`.
| Module | Use it for |
| ------------------------------- | ----------------------------------------------------------------------------------------- |
| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks |
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` |
| `@getpaseo/plugin/provider` | provider registration, connection, session, input, event, and timeline contracts |
| `@getpaseo/plugin/acp` | command-backed ACP adapter and focused transformer hooks |
### SDK import boundaries
The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:`
import reachable from client code. Shared modules cannot import runtime-owned modules. A relative
Classify every SDK export before adding it. All client entry points and implementations live under
`client/`; all server entry points and implementations live under `server/`. The package root is shared code: plain data types,
Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural
dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts.
| Entry | Owns | May depend on |
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code |
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
Server code imports shared helpers from the root and server capabilities from `/server`. Client
code imports shared helpers from the root and client capabilities from `/client`. Neither runtime
imports the other. Re-exports follow the same rule; tree shaking does not establish a boundary.
React, React Native, JSX runtimes, and client hooks must never be reachable from the root or any
server entry. Node and platform-specific code must never be reachable from the shared root.
The SDK boundary checks and real plugin-subprocess tests enforce these rules. Every SDK change
must preserve them and update the public reference, migration guide, scaffold, and examples when
an author-facing import changes. The plugin compiler enforces the same runtime entry rules for
plugin-authored code. Keep the package export map and host-provided module maps consistent.
The compiler rejects imports across runtime directories or SDK entries, React dependencies in
server code, and Node imports in client code (including bare names such as `fs`). Shared modules
cannot import runtime-owned modules. Forbidden imports fail compilation; never replace them with
empty module stubs. A relative
import to any other code file in the plugin root is also rejected; move it into `client/`, `server/`,
or `shared/`. These are compile errors naming the importing file and boundary rule. Top-level React
Native calls such as `StyleSheet.create` belong in `client/`.
@@ -190,7 +211,7 @@ pattern.
```ts
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { createGreeting } from "./server/greeting";
import { greetRpc } from "./shared/greeting";
@@ -202,7 +223,7 @@ export default function contribute(server: PluginServerContext) {
```tsx
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { Greeting } from "./client/greeting";
export default function contribute(client: PluginClientContext) {
@@ -279,8 +300,8 @@ Register a provider from `index.server.ts`. The provider connection is callback-
of its sessions; plugin RPC is not part of the provider data path.
```ts
import type { PluginServerContext } from "@getpaseo/plugin";
import type { ProviderRegistration } from "@getpaseo/plugin/provider";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
import { createProvider } from "./server/provider";
export default function contribute(server: PluginServerContext) {
@@ -306,7 +327,7 @@ persistence. Providers re-read credentials, environment, global configuration, a
`session.open`; there is no provider reload input.
For an ACP command, register `runAcpProvider({ id, label, command })` from
`@getpaseo/plugin/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct`
and `plugin-examples/provider-acp-transformer`.
@@ -438,7 +459,7 @@ credentials and vendor API calls stay in the daemon handler.
```ts
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { search } from "./server/issues";
import { searchIssues } from "./shared/issues";
@@ -450,7 +471,7 @@ export default function contribute(server: PluginServerContext) {
```tsx
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { issues } from "./shared/issues";
export default function contribute(client: PluginClientContext) {
+3
View File
@@ -11,6 +11,9 @@ The bar is four questions:
Every pull request needs evidence for the questions its change touches. Pull requests without evidence get closed.
For plugin changes, verify the [SDK import boundaries](plugins.md#sdk-import-boundaries) before
exercising contributions. Include the boundary tests and both runtime loaders in the evidence.
## Evidence
Evidence is something someone else can look at:
+1 -1
View File
@@ -1 +1 @@
sha256-zpE2CbXKy0D4tsYdeBo7JiNBNxSnB1JlnS3PtCmXKiw=
sha256-40KXWVtW9wgHYJmn7XrLHzHWdOJiSCiajj9duHnDfXA=
+1 -2
View File
@@ -34474,7 +34474,6 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -39016,6 +39015,7 @@
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -39031,7 +39031,6 @@
"@vitest/ui": "^4.1.6",
"cross-env": "^10.1.0",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
}
},
@@ -12,8 +12,8 @@ import {
const PLUGIN_ID = "plugin-host-ui-e2e";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin";
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -30,7 +30,8 @@ function isSettledWorkspaceUrl(url: URL): boolean {
function pluginClientSource(input: { workspaceId: string; agentId: string }): string {
return `import React, { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
import { Icon } from "@getpaseo/plugin/client/react-native";
import { useAgent, useWorkspace } from "@getpaseo/plugin/client";
import { recordComposerOpen } from "./shared/rpc";
function WorkspacePanel({ workspaceId, host, layout }) {
@@ -15,7 +15,7 @@ import type {
SettingsSelectProps,
SettingsInputProps,
SettingsActionProps,
} from "@getpaseo/plugin/ui";
} from "@getpaseo/plugin/client/ui";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
+3 -3
View File
@@ -1,11 +1,11 @@
import { callPluginRpc } from "@getpaseo/plugin/host";
import { callPluginRpc } from "@getpaseo/plugin/client/host";
import type {
PluginAgentCommandContext,
PluginCommandCapabilities,
PluginPanelLocation,
PluginWorkspaceCommandContext,
} from "@getpaseo/plugin";
import type { PluginClientStateSource } from "@getpaseo/plugin/host";
} from "@getpaseo/plugin/client";
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
import { resolvePluginPanelOpenLocation } from "./workspace-panels/locations";
import type { PluginSurfaceRuntime } from "./surface-runtime";
import type { InstalledPlugin } from "./types";
@@ -3,7 +3,7 @@ import { View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@getpaseo/plugin";
import { searchPluginAttachments } from "@getpaseo/plugin/host";
import { searchPluginAttachments } from "@getpaseo/plugin/client/host";
import type { LucideIcon } from "lucide-react-native";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AttachmentMenuItem } from "@/composer/input/input";
@@ -1,6 +1,6 @@
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginComposerPillProps } from "@getpaseo/plugin";
import type { PluginComposerPillProps } from "@getpaseo/plugin/client";
import { afterEach, describe, expect, it } from "vitest";
import type { InstalledPlugin } from "./types";
import { createPluginClientRuntime } from "./client-runtime";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import {
createPluginAgentActionContext,
createPluginCapabilities,
@@ -1,4 +1,4 @@
import type { PluginClientStateSource } from "@getpaseo/plugin/host";
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
import { useSessionStore } from "@/stores/session-store";
import { selectWorkspace } from "@/stores/session-store-hooks/selectors";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
@@ -4,9 +4,9 @@ import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
defineRpc,
type PluginAgentSnapshot,
type PluginCommandCenterItemContribution,
type PluginWorkspaceSnapshot,
} from "@getpaseo/plugin";
import { type PluginCommandCenterItemContribution } from "@getpaseo/plugin/client";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import type { InstalledPlugin } from "../types";
@@ -1,4 +1,4 @@
import type { PluginClientStateSource } from "@getpaseo/plugin/host";
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
import type { CommandCenterContribution } from "@/command-center/contributions";
import { getCommandCenterIcon } from "@/command-center/icon";
import { resolvePluginIcon } from "../icons";
@@ -1,5 +1,6 @@
import { PluginClientStateProvider } from "@getpaseo/plugin/host";
import type { PluginComposerPillProps, PluginTheme } from "@getpaseo/plugin";
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import type { PluginComposerPillProps } from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
import { Platform, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -1,4 +1,4 @@
import type { PluginComposerPillContribution } from "@getpaseo/plugin";
import type { PluginComposerPillContribution } from "@getpaseo/plugin/client";
import type { InstalledPlugin } from "../types";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
+39 -4
View File
@@ -441,11 +441,11 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("must return a cleanup function");
});
it("provides the host Icon component through @getpaseo/plugin", () => {
it("provides the host Icon component through @getpaseo/plugin/client/react-native", () => {
const plugin = evaluatePluginClientBundle(
"example",
`(function(require) {
const { Icon } = require("@getpaseo/plugin");
const { Icon } = require("@getpaseo/plugin/client/react-native");
const module = { exports: {} };
module.exports.default = function(plugin) {
plugin.addSurface("main", function Surface() {
@@ -463,11 +463,11 @@ describe("evaluatePluginClientBundle", () => {
expect(element).toMatchObject({ props: { size: 18, color: "#123456" } });
});
it("provides Paseo UI through @getpaseo/plugin/react-native", () => {
it("provides Paseo UI through @getpaseo/plugin/client/react-native", () => {
const plugin = evaluatePluginClientBundle(
"example",
`(function(require) {
const { Icon, Modal, useToast } = require("@getpaseo/plugin/react-native");
const { Icon, Modal, useToast } = require("@getpaseo/plugin/client/react-native");
const module = { exports: {} };
module.exports.default = function(plugin) {
if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function") {
@@ -483,6 +483,41 @@ describe("evaluatePluginClientBundle", () => {
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
});
it("keeps shared and client runtime exports separate", () => {
expect(() =>
evaluatePluginClientBundle(
"example",
`(function(require) {
const shared = require("@getpaseo/plugin");
const client = require("@getpaseo/plugin/client");
for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace"]) {
if (name in shared || typeof client[name] !== "function") throw new Error(name);
}
if ("Icon" in shared || typeof shared.PluginAttachmentItemSchema.parse !== "function") throw new Error("shared exports");
return { default() { return () => {}; } };
})`,
),
).not.toThrow();
});
it.each([
"@getpaseo/plugin/server",
"@getpaseo/plugin/server/provider",
"@getpaseo/plugin/server/acp",
"@getpaseo/plugin/client/host",
"@getpaseo/plugin/react-native",
"@getpaseo/plugin/ui",
"@getpaseo/plugin/host",
"@paseo/plugin",
])("rejects %s in the client loader", (specifier) => {
expect(() =>
evaluatePluginClientBundle(
"example",
`(function(require) { require("${specifier}"); return {}; })`,
),
).toThrow("not available in plugin client code");
});
it("resolves shared RPC helpers from @getpaseo/plugin", () => {
const plugin = evaluatePluginClientBundle(
"example",
+12 -29
View File
@@ -1,6 +1,7 @@
import * as pluginUiRuntime from "./react-native/ui";
import { useSettings } from "./settings/use-settings";
import { defineSettings } from "@getpaseo/plugin";
import * as pluginSharedRuntime from "@getpaseo/plugin";
import * as pluginClientRuntime from "@getpaseo/plugin/client";
import * as React from "react";
import * as ReactJsxRuntime from "react/jsx-runtime";
// eslint-disable-next-line no-restricted-imports -- plugin client runtime injects host ReactNative.
@@ -9,27 +10,23 @@ import * as ReactNative from "react-native";
import * as ReactQuery from "@tanstack/react-query";
import * as Zod from "zod";
import {
defineAttachmentSource,
defineRpc,
type PluginAttachmentSourceContribution,
type PluginCommandCenterItemContribution,
type PluginCleanup,
type PluginThemeContribution,
} from "@getpaseo/plugin";
import {
type PluginCommandCenterItemContribution,
type PluginClientContext,
type PluginClientSlashCommandContribution,
type PluginSidebarContribution,
type PluginSurfaceProps,
type PluginThemeContribution,
type PluginTimelineRendererContribution,
type PluginTimelineTransformerContribution,
type PluginWorkspacePanelContribution,
usePaseo,
useAgent,
useWorkspace,
useRpc,
} from "@getpaseo/plugin";
} from "@getpaseo/plugin/client";
import type { EvaluatedPlugin } from "./types";
import type { ComponentType } from "react";
import { Icon, resolvePluginIcon } from "./icons";
import { resolvePluginIcon } from "./icons";
import { pluginReactNativeRuntime } from "./react-native/runtime";
import { parsePluginThemeContribution } from "./themes";
@@ -352,29 +349,15 @@ export function runPluginClientBundle(
},
};
const runtimeRequire = (name: string): unknown => {
if (name === "@getpaseo/plugin/ui") return pluginUiRuntime;
if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime;
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
if (name === "react-native") return ReactNative;
if (name === "@getpaseo/plugin") {
return {
defineAttachmentSource,
defineRpc,
defineSettings,
useSettings,
Icon,
usePaseo,
useAgent,
useWorkspace,
useRpc,
};
}
if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") {
if (name === "@getpaseo/plugin") return pluginSharedRuntime;
if (name === "@getpaseo/plugin/client") return { ...pluginClientRuntime, useSettings };
if (name === "@getpaseo/plugin/client/react-native") {
return pluginReactNativeRuntime;
}
if (name === "@getpaseo/plugin/server" || name === "@paseo/plugin/server") {
return {};
}
if (name === "@tanstack/react-query") return ReactQuery;
if (name === "zod") return Zod;
throw new Error(`Module "${name}" is not available in plugin client code`);
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PluginSurfaceProps } from "@getpaseo/plugin";
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
+1 -1
View File
@@ -1,6 +1,6 @@
import { createElement, type ReactElement } from "react";
import * as LucideIcons from "lucide-react-native";
import type { PluginIconProps } from "@getpaseo/plugin";
import type { PluginIconProps } from "@getpaseo/plugin/client";
import type { LucideIcon } from "lucide-react-native";
function findPluginIcon(name: string): LucideIcon | null {
+1 -1
View File
@@ -1,6 +1,6 @@
import { buildPluginSettingsRoute } from "./settings/routes";
import { router } from "expo-router";
import type { PluginPanelLocation } from "@getpaseo/plugin";
import type { PluginPanelLocation } from "@getpaseo/plugin/client";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildPluginSurfaceRoute } from "./routes";
@@ -1,6 +1,10 @@
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import type { ModalComponent, ModalContentProps, ModalProps } from "@getpaseo/plugin/react-native";
import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/host";
import type {
ModalComponent,
ModalContentProps,
ModalProps,
} from "@getpaseo/plugin/client/react-native";
import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/client/host";
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
import { ToastApiProvider, useToast as useAppToast } from "@/contexts/toast-api-context";
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import type { ToastApi, ToastOptions } from "@getpaseo/plugin/react-native";
import type { ToastApi, ToastOptions } from "@getpaseo/plugin/client/react-native";
import { useToast as useAppToast } from "@/contexts/toast-api-context";
export function useToast(): ToastApi {
@@ -1,5 +1,5 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/host";
import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host";
import type { ReactNode } from "react";
import type { InstalledPlugin } from "./types";
import type { PluginSurfaceRuntime } from "./surface-runtime";
+1 -1
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { Platform, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { router } from "expo-router";
import type { PluginHostProps } from "@getpaseo/plugin";
import type { PluginHostProps } from "@getpaseo/plugin/client";
import { SettingsAction } from "@/components/settings";
import { Button } from "@/components/ui/button";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -1,7 +1,9 @@
import type { SettingsState } from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRpc, type SettingsDefinition, type SettingsState } from "@getpaseo/plugin";
import { settingsRpc } from "@getpaseo/plugin/host";
import { useRpc } from "@getpaseo/plugin/client";
import { type SettingsDefinition } from "@getpaseo/plugin";
import { settingsRpc } from "@getpaseo/plugin";
import { useReplicaQuery } from "@/data/query";
import { z, type ZodType } from "zod";
@@ -1,7 +1,7 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PaseoApi } from "@getpaseo/client";
import { PaseoApiProvider } from "@getpaseo/plugin/host";
import { usePaseo } from "@getpaseo/plugin";
import { PaseoApiProvider } from "@getpaseo/plugin/client/host";
import { usePaseo } from "@getpaseo/plugin/client";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
+2 -1
View File
@@ -1,5 +1,6 @@
import { router, useLocalSearchParams } from "expo-router";
import type { PluginSurfaceProps, PluginTheme } from "@getpaseo/plugin";
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { ChevronDown, X } from "lucide-react-native";
import { useCallback, useMemo, useRef, useState, type ComponentType } from "react";
import { Platform, Pressable, Text, View } from "react-native";
+3 -2
View File
@@ -1,5 +1,6 @@
import { PluginClientStateProvider } from "@getpaseo/plugin/host";
import type { PluginHostProps, PluginTheme, PluginTimelineItemProps } from "@getpaseo/plugin";
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import type { PluginHostProps, PluginTimelineItemProps } from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import React, { type ComponentType, useMemo } from "react";
import { Platform, Text } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
+5 -3
View File
@@ -2,19 +2,21 @@ import type { QueryClient } from "@tanstack/react-query";
import type { PluginRequirements } from "@getpaseo/protocol/messages";
import type {
PluginAttachmentSourceContribution,
PluginCleanup,
PluginThemeContribution,
} from "@getpaseo/plugin";
import type {
PluginCommandCenterItemContribution,
PluginClientSlashCommandContribution,
PluginCleanup,
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSettingsScreenContribution,
PluginThemeContribution,
PluginTimelineRendererContribution,
PluginTimelineTransformerContribution,
PluginPanelLocation,
PluginWorkspacePanelContribution,
} from "@getpaseo/plugin";
} from "@getpaseo/plugin/client";
export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelContribution & {
locations: readonly PluginPanelLocation[];
@@ -1,4 +1,7 @@
import type { PluginPanelLocation, PluginWorkspacePanelContribution } from "@getpaseo/plugin";
import type {
PluginPanelLocation,
PluginWorkspacePanelContribution,
} from "@getpaseo/plugin/client";
import type { PaneHost } from "@/panels/panel-manifest";
import { panelSupportsHost } from "@/panels/panel-manifest";
import type { PluginWorkspaceTabTarget, WorkspaceTabTarget } from "@/workspace-tabs/model";
@@ -1,10 +1,10 @@
import type {
PluginAgentPanelProps,
PluginHostProps,
PluginTheme,
PluginWorkspacePanelProps,
} from "@getpaseo/plugin";
import { PluginClientStateProvider } from "@getpaseo/plugin/host";
} from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import { CircleAlert } from "lucide-react-native";
import { useMemo } from "react";
import { Platform, Text, View } from "react-native";
@@ -1,4 +1,4 @@
import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin";
import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin/client";
import type { InstalledPlugin } from "../types";
import type { PluginWorkspaceTabTarget } from "@/workspace-tabs/model";
@@ -141,16 +141,8 @@ export async function inspectConfig(
path.join(directory, "client", "main.tsx"),
`import React from "react";
import { Text } from "react-native";
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
import {
type PluginAgentPanelProps,
type PluginClientContext,
type PluginComposerPillProps,
type PluginSurfaceProps,
useAgent,
usePaseo,
useWorkspace,
} from "@getpaseo/plugin";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
import { type PluginAgentPanelProps, type PluginClientContext, type PluginComposerPillProps, type PluginSurfaceProps, useAgent, usePaseo, useWorkspace } from "@getpaseo/plugin/client";
import { inspect } from "../shared/inspect";
export function Surface({ navigation }: PluginSurfaceProps) {
@@ -200,7 +192,7 @@ export function contributeClient(client: PluginClientContext) {
),
writeFile(
path.join(directory, "index.client.tsx"),
`import type { PluginClientContext } from "@getpaseo/plugin";
`import type { PluginClientContext } from "@getpaseo/plugin/client";
import { AgentPanel, contributeClient, Surface } from "./client/main";
import { inspect } from "./shared/inspect";
@@ -230,7 +222,7 @@ export default function contribute(client: PluginClientContext) {
),
writeFile(
path.join(directory, "index.server.ts"),
`import type { PluginServerContext } from "@getpaseo/plugin";
`import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inspectConfig } from "./server/inspect";
import { inspect } from "./shared/inspect";
+4 -4
View File
@@ -20,7 +20,7 @@ const TSCONFIG = {
include: ["**/*.ts", "**/*.tsx"],
};
const CLIENT_ENTRY = `import type { PluginClientContext } from "@getpaseo/plugin";
const CLIENT_ENTRY = `import type { PluginClientContext } from "@getpaseo/plugin/client";
import { GreetingSurface } from "./client/greeting";
export default function contribute(client: PluginClientContext) {
@@ -35,7 +35,7 @@ export default function contribute(client: PluginClientContext) {
}
`;
const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin";
const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin/server";
import { createGreeting } from "./server/greeting";
import { greetingRpc } from "./shared/greeting";
@@ -63,8 +63,8 @@ export function createGreeting({ name }: RpcInput<typeof greetingRpc>) {
}
`;
const CLIENT_GREETING = `import type { PluginSurfaceProps } from "@getpaseo/plugin";
import { useRpc } from "@getpaseo/plugin";
const CLIENT_GREETING = `import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
import { useRpc } from "@getpaseo/plugin/client";
import { useMutation } from "@tanstack/react-query";
import { useMemo } from "react";
import { Pressable, Text, View } from "react-native";
+28 -23
View File
@@ -14,34 +14,39 @@
"default": "./dist/index.js"
},
"./server": {
"types": "./dist/server.d.ts",
"source": "./src/server.ts",
"default": "./dist/server.js"
"types": "./dist/server/index.d.ts",
"source": "./src/server/index.ts",
"default": "./dist/server/index.js"
},
"./provider": {
"types": "./dist/provider.d.ts",
"source": "./src/provider.ts",
"default": "./dist/provider.js"
"./server/provider": {
"types": "./dist/server/provider.d.ts",
"source": "./src/server/provider.ts",
"default": "./dist/server/provider.js"
},
"./acp": {
"types": "./dist/acp.d.ts",
"source": "./src/acp.ts",
"default": "./dist/acp.js"
"./server/acp": {
"types": "./dist/server/acp.d.ts",
"source": "./src/server/acp.ts",
"default": "./dist/server/acp.js"
},
"./react-native": {
"types": "./dist/react-native.d.ts",
"source": "./src/react-native.ts",
"default": "./dist/react-native.js"
"./client/react-native": {
"types": "./dist/client/react-native.d.ts",
"source": "./src/client/react-native.ts",
"default": "./dist/client/react-native.js"
},
"./host": {
"types": "./dist/host.d.ts",
"source": "./src/host.ts",
"default": "./dist/host.js"
"./client/host": {
"types": "./dist/client/host.d.ts",
"source": "./src/client/host.ts",
"default": "./dist/client/host.js"
},
"./ui": {
"types": "./dist/ui.d.ts",
"source": "./src/ui.ts",
"default": "./dist/ui.js"
"./client/ui": {
"types": "./dist/client/ui.d.ts",
"source": "./src/client/ui.ts",
"default": "./dist/client/ui.js"
},
"./client": {
"types": "./dist/client/index.d.ts",
"source": "./src/client/index.ts",
"default": "./dist/client/index.js"
}
},
"publishConfig": {
+7
View File
@@ -1,4 +1,11 @@
import { z } from "zod";
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
return definition;
}
export const PluginAttachmentItemSchema = z.object({
id: z.string(),
+123
View File
@@ -0,0 +1,123 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { isBuiltin } from "node:module";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { describe, expect, it } from "vitest";
const sourceDirectory = path.dirname(fileURLToPath(import.meta.url));
const entries = {
".": "shared",
"./server": "server",
"./server/provider": "server",
"./server/acp": "server",
"./client": "client",
"./client/host": "client",
"./client/react-native": "client",
"./client/ui": "client",
} as const;
const uiDependency = /^(react(?:-dom|-native)?|use-sync-external-store)(\/|$)/;
const serverModule = /^server\//;
const clientModule = /^client\//;
function resolveLocal(importer: string, specifier: string): string {
const base = path.resolve(path.dirname(importer), specifier.replace(/\.js$/, ""));
const result = [base + ".ts", base + ".tsx"].find(existsSync);
if (!result) throw new Error(`Cannot resolve ${specifier} from ${importer}`);
return result;
}
function boundaryViolations(entry: string, runtime: "shared" | "server" | "client"): string[] {
const pending = [entry];
const visited = new Set<string>();
const violations: string[] = [];
while (pending.length) {
const file = pending.pop()!;
if (visited.has(file)) continue;
visited.add(file);
// Includes type imports, re-exports, import types, and literal dynamic imports.
const imports = ts.preProcessFile(readFileSync(file, "utf8"), true, true).importedFiles;
for (const { fileName: specifier } of imports) {
const label = `${path.relative(sourceDirectory, file)} -> ${specifier}`;
if (specifier.startsWith(".")) {
const local = resolveLocal(file, specifier);
const module = path
.relative(sourceDirectory, local)
.replace(/\\/g, "/")
.replace(/\.tsx?$/, ".js");
if (runtime !== "client" && clientModule.test(module)) violations.push(label);
if (runtime !== "server" && serverModule.test(module)) violations.push(label);
pending.push(local);
} else if (runtime === "shared") {
if (specifier !== "zod" && specifier !== "@getpaseo/protocol/agent-types")
violations.push(label);
} else if (runtime === "server") {
if (uiDependency.test(specifier) || /^@getpaseo\/plugin\/client(\/|$)/.test(specifier))
violations.push(label);
} else if (isBuiltin(specifier) || /^@getpaseo\/plugin\/server(\/|$)/.test(specifier)) {
violations.push(label);
}
}
}
return violations;
}
describe("plugin SDK import boundaries", () => {
it("classifies every published entry", () => {
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
expect(Object.keys(manifest.exports).sort()).toEqual(Object.keys(entries).sort());
});
it.each(Object.entries(entries))(
"%s respects its %s boundary, including type dependencies",
(specifier, runtime) => {
const name =
specifier === "."
? "index"
: specifier.slice(2) +
(specifier === "./client" || specifier === "./server" ? "/index" : "");
expect(boundaryViolations(path.join(sourceDirectory, `${name}.ts`), runtime)).toEqual([]);
},
);
});
describe("plugin example import boundaries", () => {
const examples = path.resolve(sourceDirectory, "../../../plugin-examples");
const files = readdirSync(examples, { recursive: true }).filter((file) =>
/(?<!\.test)\.tsx?$/.test(file),
);
const owner = (file: string) => {
const [, directory] = path.relative(examples, file).split(path.sep);
if (directory?.startsWith("index.client.")) return "client";
if (directory?.startsWith("index.server.")) return "server";
return directory;
};
it.each(files)("%s respects runtime ownership, including type imports", (relative) => {
const file = path.join(examples, relative);
const runtime = owner(file);
expect(["shared", "client", "server"]).toContain(runtime);
const violations: string[] = [];
for (const { fileName: specifier } of ts.preProcessFile(readFileSync(file, "utf8"), true, true)
.importedFiles) {
if (specifier.startsWith(".")) {
const imported = owner(resolveLocal(file, specifier));
if (imported !== "shared" && imported !== runtime) violations.push(specifier);
} else if (specifier.startsWith("@getpaseo/plugin")) {
const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries;
if (
entry === "./client/host" ||
!(entry in entries) ||
(entries[entry] !== "shared" && entries[entry] !== runtime)
)
violations.push(specifier);
} else if (
(runtime !== "client" && uiDependency.test(specifier)) ||
(runtime !== "server" && isBuiltin(specifier))
) {
violations.push(specifier);
}
}
expect(violations).toEqual([]);
});
});
@@ -1,6 +1,6 @@
import { createContext, useContext, type ReactNode } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "./contracts.js";
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "../contracts.js";
import { shallowEqual } from "./shallow.js";
export interface PluginClientStateSource {
+246
View File
@@ -0,0 +1,246 @@
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "../rpc.js";
import type {
PluginTheme,
PluginWorkspaceSnapshot,
PluginAgentSnapshot,
PluginThemeContribution,
PluginAttachmentSourceContribution,
PluginTimelineTransformResult,
PluginCleanup,
} from "../contracts.js";
export interface PluginHostProps {
theme: PluginTheme;
host: {
id: string;
label: string;
};
layout: {
compact: boolean;
platform: "ios" | "android" | "web";
};
}
interface PluginNavigableHostProps extends PluginHostProps {
/** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */
readonly navigation?: {
readonly openAgent: (input: { readonly agentId: string }) => void;
readonly openWorkspace: (input: { readonly workspaceId: string }) => void;
};
}
export interface PluginSurfaceProps extends PluginNavigableHostProps {}
export interface PluginIconProps {
name: string;
size?: number;
color?: string;
}
export type PluginPanelLocation = "workspace" | "explorer";
export interface PluginOpenPanelOptions {
location?: PluginPanelLocation;
}
interface PluginWorkspacePanelBase {
id: string;
title: string;
icon: string;
locations?: readonly PluginPanelLocation[];
}
export interface PluginWorkspacePanelProps extends PluginNavigableHostProps {
context: "workspace";
workspaceId: string;
}
export interface PluginAgentPanelProps extends PluginNavigableHostProps {
context: "agent";
workspaceId: string;
agentId: string;
}
export interface PluginComposerPillProps extends PluginHostProps {
workspaceId: string;
agentId: string;
}
export interface PluginComposerPillContribution {
id: string;
title: string;
workspaceId: string;
agentId: string;
Component: ComponentType<PluginComposerPillProps>;
onPress(): void | Promise<void>;
}
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
workspaceId: string;
agentId?: string;
}
export interface PluginClientContext extends PluginCommandCapabilities {
addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): PluginCleanup;
addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup;
addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup;
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup;
addTheme(contribution: PluginThemeContribution): PluginCleanup;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
contribution: PluginTimelineTransformerContribution<ItemType>,
): PluginCleanup;
addTimelineRenderer<Schema extends ZodType>(
contribution: PluginTimelineRendererContribution<Schema>,
): PluginCleanup;
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
}
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
export type PluginWorkspacePanelContribution =
| (PluginWorkspacePanelBase & {
context: "workspace";
Component: ComponentType<PluginWorkspacePanelProps>;
})
| (PluginWorkspacePanelBase & {
context: "agent";
Component: ComponentType<PluginAgentPanelProps>;
});
export interface PluginSettingsScreenContribution {
id: string;
title: string;
icon: string;
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginSurfaceContribution {
id: string;
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginSidebarContribution {
id: string;
title: string;
icon: string;
surface: string;
}
export type PluginTimelineTransformerContribution<
ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"],
> = ItemType extends AgentTimelineItem["type"]
? {
id: string;
query: {
itemType: ItemType;
};
transform(input: {
item: Extract<AgentTimelineItem, { type: ItemType }>;
phase: "streaming" | "complete";
}): PluginTimelineTransformResult | undefined;
}
: never;
export interface PluginTimelineItemProps<Data = unknown> extends PluginHostProps {
agentId: string;
item: {
type: "plugin";
kind: string;
version: number;
data: Data;
};
timestamp: Date;
}
export interface PluginTimelineRendererContribution<Schema extends ZodType = ZodType> {
kind: string;
version: number;
schema: Schema;
Component: ComponentType<PluginTimelineItemProps<ZodOutput<Schema>>>;
}
export interface PluginCommandCapabilities {
paseo: PaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: ZodInput<InputSchema>,
): Promise<ZodOutput<OutputSchema>>;
openSurface(id: string): void;
openSettings(id: string): void;
}
export interface PluginGlobalCommandContext extends PluginCommandCapabilities {
context: "global";
}
export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities {
context: "workspace";
workspace: PluginWorkspaceSnapshot;
openPanel(id: string, options?: PluginOpenPanelOptions): void;
}
export interface PluginAgentCommandContext extends PluginCommandCapabilities {
context: "agent";
workspace: PluginWorkspaceSnapshot;
agent: PluginAgentSnapshot;
openPanel(id: string, options?: PluginOpenPanelOptions): void;
}
interface PluginCommandCenterItemBase {
id: string;
title: string;
icon: string;
keywords?: readonly string[];
}
export type PluginCommandCenterItemContribution =
| (PluginCommandCenterItemBase & {
context: "global";
onSelect(context: PluginGlobalCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
context: "workspace";
onSelect(context: PluginWorkspaceCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
context: "agent";
onSelect(context: PluginAgentCommandContext): void | Promise<void>;
});
interface PluginClientSlashCommandBase {
name: string;
description: string;
argumentHint: string;
}
export type PluginClientSlashCommandContribution =
| (PluginClientSlashCommandBase & {
context: "workspace";
onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise<void>;
})
| (PluginClientSlashCommandBase & {
context: "agent";
onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise<void>;
});
export type SettingsState<Schema extends ZodType> = (
| { status: "loading" }
| { status: "error"; error: string }
| { status: "invalid"; error: string; revision: string }
| { status: "ready"; values: ZodOutput<Schema>; revision: string }
) & {
saving: boolean;
saveError: string | null;
/** Save an entire document against the revision currently displayed. Never throws. */
save(values: ZodOutput<Schema>, revision: string): Promise<boolean>;
reset(): Promise<boolean>;
reload(): Promise<void>;
};
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import { z } from "zod";
import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "./index.js";
import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "../index.js";
import { callPluginRpc, searchPluginAttachments } from "./host.js";
const contract = defineRpc({
@@ -1,14 +1,13 @@
export { settingsRpc } from "./settings.js";
import { PluginAttachmentSearchPayloadSchema } from "./attachments.js";
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
export {
usePluginRuntimeContextBridge,
type PluginRuntimeContextBridge,
} from "./runtime-context-bridge.js";
import type { PluginAttachmentSourceContribution } from "./contracts.js";
import type { PluginAttachmentSourceContribution } from "../contracts.js";
import { PluginRpcProvider } from "./rpc-context.js";
import { PaseoApiProvider } from "./paseo-context.js";
import { callPluginRpc } from "./rpc.js";
import { callPluginRpc } from "../rpc.js";
export async function searchPluginAttachments(
source: PluginAttachmentSourceContribution,
+37
View File
@@ -0,0 +1,37 @@
export type {
PluginHostProps,
PluginSurfaceProps,
PluginIconProps,
PluginPanelLocation,
PluginOpenPanelOptions,
PluginWorkspacePanelProps,
PluginAgentPanelProps,
PluginComposerPillProps,
PluginComposerPillContribution,
PluginClientOpenPanelOptions,
PluginClientContext,
PluginClientContribution,
PluginWorkspacePanelContribution,
PluginSettingsScreenContribution,
PluginSurfaceContribution,
PluginSidebarContribution,
PluginTimelineTransformerContribution,
PluginTimelineItemProps,
PluginTimelineRendererContribution,
PluginCommandCapabilities,
PluginGlobalCommandContext,
PluginWorkspaceCommandContext,
PluginAgentCommandContext,
PluginCommandCenterItemContribution,
PluginClientSlashCommandContribution,
SettingsState,
} from "./contracts.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc } from "./rpc-context.js";
import type { SettingsDefinition } from "../settings.js";
import type { SettingsState } from "./contracts.js";
import type { ZodType } from "zod";
export declare function useSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
): SettingsState<Schema>;
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import { callPluginRpc, type PluginRpcContract } from "./rpc.js";
import { callPluginRpc, type PluginRpcContract } from "../rpc.js";
export interface PluginRpcContextValue {
invoke(method: string, input: unknown): Promise<unknown>;
+1 -244
View File
@@ -1,10 +1,5 @@
import type { PluginLifecycleRegistration } from "./lifecycle.js";
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginRpcContract } from "./rpc.js";
import type { ProviderRegistration } from "./provider.js";
export interface PluginTheme {
readonly colors: {
@@ -22,34 +17,6 @@ export interface PluginTheme {
};
}
export interface PluginHostProps {
theme: PluginTheme;
host: {
id: string;
label: string;
};
layout: {
compact: boolean;
platform: "ios" | "android" | "web";
};
}
interface PluginNavigableHostProps extends PluginHostProps {
/** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */
readonly navigation?: {
readonly openAgent: (input: { readonly agentId: string }) => void;
readonly openWorkspace: (input: { readonly workspaceId: string }) => void;
};
}
export interface PluginSurfaceProps extends PluginNavigableHostProps {}
export interface PluginIconProps {
name: string;
size?: number;
color?: string;
}
export interface PluginWorkspaceSnapshot {
readonly id: string;
readonly projectId: string;
@@ -85,99 +52,6 @@ export interface PluginAgentSnapshot {
readonly labels: Readonly<Record<string, string>>;
}
export type PluginPanelLocation = "workspace" | "explorer";
export interface PluginOpenPanelOptions {
location?: PluginPanelLocation;
}
interface PluginWorkspacePanelBase {
id: string;
title: string;
icon: string;
locations?: readonly PluginPanelLocation[];
}
export interface PluginWorkspacePanelProps extends PluginNavigableHostProps {
context: "workspace";
workspaceId: string;
}
export interface PluginAgentPanelProps extends PluginNavigableHostProps {
context: "agent";
workspaceId: string;
agentId: string;
}
export interface PluginComposerPillProps extends PluginHostProps {
workspaceId: string;
agentId: string;
}
export interface PluginComposerPillContribution {
id: string;
title: string;
workspaceId: string;
agentId: string;
Component: ComponentType<PluginComposerPillProps>;
onPress(): void | Promise<void>;
}
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
workspaceId: string;
agentId?: string;
}
export interface PluginClientContext extends PluginCommandCapabilities {
addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): PluginCleanup;
addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup;
addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup;
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup;
addTheme(contribution: PluginThemeContribution): PluginCleanup;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
contribution: PluginTimelineTransformerContribution<ItemType>,
): PluginCleanup;
addTimelineRenderer<Schema extends ZodType>(
contribution: PluginTimelineRendererContribution<Schema>,
): PluginCleanup;
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
}
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
export type PluginWorkspacePanelContribution =
| (PluginWorkspacePanelBase & {
context: "workspace";
Component: ComponentType<PluginWorkspacePanelProps>;
})
| (PluginWorkspacePanelBase & {
context: "agent";
Component: ComponentType<PluginAgentPanelProps>;
});
export interface PluginSettingsScreenContribution {
id: string;
title: string;
icon: string;
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginSurfaceContribution {
id: string;
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginSidebarContribution {
id: string;
title: string;
icon: string;
surface: string;
}
export interface PluginThemeColors {
background: string;
foreground: string;
@@ -219,121 +93,4 @@ export interface PluginTimelineTransformResult {
items: PluginTimelineItem[];
}
export type PluginTimelineTransformerContribution<
ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"],
> = ItemType extends AgentTimelineItem["type"]
? {
id: string;
query: {
itemType: ItemType;
};
transform(input: {
item: Extract<AgentTimelineItem, { type: ItemType }>;
phase: "streaming" | "complete";
}): PluginTimelineTransformResult | undefined;
}
: never;
export interface PluginTimelineItemProps<Data = unknown> extends PluginHostProps {
agentId: string;
item: {
type: "plugin";
kind: string;
version: number;
data: Data;
};
timestamp: Date;
}
export interface PluginTimelineRendererContribution<Schema extends ZodType = ZodType> {
kind: string;
version: number;
schema: Schema;
Component: ComponentType<PluginTimelineItemProps<ZodOutput<Schema>>>;
}
export interface PluginCommandCapabilities {
paseo: PaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: ZodInput<InputSchema>,
): Promise<ZodOutput<OutputSchema>>;
openSurface(id: string): void;
openSettings(id: string): void;
}
export interface PluginGlobalCommandContext extends PluginCommandCapabilities {
context: "global";
}
export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities {
context: "workspace";
workspace: PluginWorkspaceSnapshot;
openPanel(id: string, options?: PluginOpenPanelOptions): void;
}
export interface PluginAgentCommandContext extends PluginCommandCapabilities {
context: "agent";
workspace: PluginWorkspaceSnapshot;
agent: PluginAgentSnapshot;
openPanel(id: string, options?: PluginOpenPanelOptions): void;
}
interface PluginCommandCenterItemBase {
id: string;
title: string;
icon: string;
keywords?: readonly string[];
}
export type PluginCommandCenterItemContribution =
| (PluginCommandCenterItemBase & {
context: "global";
onSelect(context: PluginGlobalCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
context: "workspace";
onSelect(context: PluginWorkspaceCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
context: "agent";
onSelect(context: PluginAgentCommandContext): void | Promise<void>;
});
interface PluginClientSlashCommandBase {
name: string;
description: string;
argumentHint: string;
}
export type PluginClientSlashCommandContribution =
| (PluginClientSlashCommandBase & {
context: "workspace";
onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise<void>;
})
| (PluginClientSlashCommandBase & {
context: "agent";
onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise<void>;
});
export interface PluginHandlerContext {
paseo: PaseoApi;
}
export interface PluginServerContext extends PluginLifecycleRegistration {
registerSettings<Schema extends ZodType>(
definition: import("./settings.js").SettingsDefinition<Schema>,
): void;
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
input: ZodOutput<InputSchema>,
context: PluginHandlerContext,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void;
registerProvider(provider: ProviderRegistration): void;
}
export type PluginCleanup = () => void | Promise<void>;
export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup;
+15 -72
View File
@@ -1,79 +1,22 @@
import type { SettingsDefinition, SettingsState } from "./settings.js";
import type { ZodType } from "zod";
export { defineSettings, type SettingsDefinition, type SettingsState } from "./settings.js";
export declare function useSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
): SettingsState<Schema>;
import type { ComponentType } from "react";
import type { PluginAttachmentSourceContribution, PluginIconProps } from "./contracts.js";
// Shared SDK entry. Keep runtime-specific imports and re-exports on /client or /server.
export type {
PluginTheme,
PluginWorkspaceSnapshot,
PluginAgentSnapshot,
PluginThemeColors,
PluginThemeContribution,
PluginAttachmentSourceContribution,
PluginTimelineData,
PluginTimelineItem,
PluginTimelineTransformResult,
PluginCleanup,
} from "./contracts.js";
export { defineSettings, settingsRpc, type SettingsDefinition } from "./settings.js";
export {
defineAttachmentSource,
PluginAttachmentItemSchema,
PluginAttachmentSearchPayloadSchema,
type PluginAttachmentItem,
type PluginAttachmentSearchPayload,
} from "./attachments.js";
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
return definition;
}
export declare const Icon: ComponentType<PluginIconProps>;
export type {
PluginAttachmentSourceContribution,
PluginAgentCommandContext,
PluginAgentPanelProps,
PluginAgentSnapshot,
PluginCleanup,
PluginCommandCapabilities,
PluginCommandCenterItemContribution,
PluginClientContext,
PluginClientContribution,
PluginClientSlashCommandContribution,
PluginClientOpenPanelOptions,
PluginComposerPillContribution,
PluginComposerPillProps,
PluginServerContribution,
PluginServerContext,
PluginGlobalCommandContext,
PluginHandlerContext,
PluginHostProps,
PluginOpenPanelOptions,
PluginIconProps,
PluginPanelLocation,
PluginTheme,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSettingsScreenContribution,
PluginSurfaceProps,
PluginThemeColors,
PluginThemeContribution,
PluginTimelineData,
PluginTimelineItem,
PluginTimelineItemProps,
PluginTimelineRendererContribution,
PluginTimelineTransformerContribution,
PluginTimelineTransformResult,
PluginWorkspaceCommandContext,
PluginWorkspacePanelContribution,
PluginWorkspacePanelProps,
PluginWorkspaceSnapshot,
} from "./contracts.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc } from "./rpc-context.js";
export type {
PluginBeforeRequests,
PluginHookAgent,
PluginHookContext,
PluginHookWorkspace,
PluginLifecycleEvents,
PluginLifecycleRegistration,
PluginSessionOpenRequest,
PluginTurnOutcome,
} from "./lifecycle.js";
+1 -1
View File
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
describe("plugin runtime-entry migration guide", () => {
it("maps every client registration method", async () => {
const contracts = await readFile(new URL("./contracts.ts", import.meta.url), "utf8");
const contracts = await readFile(new URL("./client/contracts.ts", import.meta.url), "utf8");
const context = /export interface PluginClientContext[^{]*{([\s\S]*?)\n}/.exec(contracts)?.[1];
if (!context) throw new Error("PluginClientContext was not found");
const methods = [...context.matchAll(/^\s+(add[A-Z]\w*)\(/gm)].map((match) => match[1]);
-57
View File
@@ -1,57 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const srcDir = path.dirname(fileURLToPath(import.meta.url));
const forbiddenSpecifiers = new Set([
"react",
"react/jsx-runtime",
"react-native",
"use-sync-external-store",
"use-sync-external-store/shim/with-selector",
]);
function localFile(fromFile: string, specifier: string): string {
const withoutJs = specifier.replace(/\.js$/, "");
for (const candidate of [`${withoutJs}.ts`, `${withoutJs}.tsx`, withoutJs]) {
const resolved = path.resolve(path.dirname(fromFile), candidate);
if (existsSync(resolved)) return resolved;
}
throw new Error(`Could not resolve ${specifier} from ${fromFile}`);
}
function valueImportSpecifiers(source: string): string[] {
const specifiers: string[] = [];
const pattern = /(?:^|\n)(?:export|import)\s+(?!type\b)(?:[\s\S]*?)from\s+["']([^"']+)["']/g;
for (const match of source.matchAll(pattern)) {
if (match[1]) specifiers.push(match[1]);
}
return specifiers;
}
function collectBareSpecifiers(entry: string): string[] {
const seen = new Set<string>();
const bare: string[] = [];
const queue = [entry];
while (queue.length > 0) {
const file = queue.pop();
if (!file || seen.has(file)) continue;
seen.add(file);
for (const specifier of valueImportSpecifiers(readFileSync(file, "utf8"))) {
if (specifier.startsWith(".")) {
queue.push(localFile(file, specifier));
continue;
}
bare.push(specifier);
}
}
return bare;
}
describe("@getpaseo/plugin/server", () => {
it("does not load react or the client hook graph", () => {
const specifiers = collectBareSpecifiers(path.join(srcDir, "server.ts"));
expect(specifiers.filter((specifier) => forbiddenSpecifiers.has(specifier))).toEqual([]);
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "../rpc.js";
import type { PluginCleanup } from "../contracts.js";
import type { ProviderRegistration } from "./provider.js";
import type { PluginLifecycleRegistration } from "./lifecycle.js";
export interface PluginHandlerContext {
paseo: PaseoApi;
}
export interface PluginServerContext extends PluginLifecycleRegistration {
registerSettings<Schema extends ZodType>(
definition: import("../settings.js").SettingsDefinition<Schema>,
): void;
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
input: ZodOutput<InputSchema>,
context: PluginHandlerContext,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void;
registerProvider(provider: ProviderRegistration): void;
}
export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup;
@@ -1,12 +1,15 @@
export type { PluginHandlerContext } from "./contracts.js";
export type {
PluginBeforeRequests,
PluginHookAgent,
PluginHandlerContext,
PluginServerContext,
PluginServerContribution,
} from "./contracts.js";
export type {
PluginHookContext,
PluginHookWorkspace,
PluginLifecycleEvents,
PluginLifecycleRegistration,
PluginHookAgent,
PluginSessionOpenRequest,
PluginTurnOutcome,
PluginLifecycleEvents,
PluginBeforeRequests,
PluginLifecycleRegistration,
} from "./lifecycle.js";
+1 -15
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import type { output, ZodType } from "zod";
import type { ZodType } from "zod";
import { defineRpc } from "./rpc.js";
export interface SettingsDefinition<Schema extends ZodType = ZodType> {
@@ -49,17 +49,3 @@ export function settingsRpc(id: string) {
}),
};
}
export type SettingsState<Schema extends ZodType> = (
| { status: "loading" }
| { status: "error"; error: string }
| { status: "invalid"; error: string; revision: string }
| { status: "ready"; values: output<Schema>; revision: string }
) & {
saving: boolean;
saveError: string | null;
/** Save an entire document against the revision currently displayed. Never throws. */
save(values: output<Schema>, revision: string): Promise<boolean>;
reset(): Promise<boolean>;
reload(): Promise<void>;
};
+1 -1
View File
@@ -101,6 +101,7 @@
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -116,7 +117,6 @@
"@vitest/ui": "^4.1.6",
"cross-env": "^10.1.0",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
},
"overrides": {
@@ -3,7 +3,7 @@ import type {
ProviderEvent,
ProviderInput,
ProviderRegistration,
} from "@getpaseo/plugin/provider";
} from "@getpaseo/plugin/server/provider";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "./agent-sdk-types.js";
@@ -21,7 +21,7 @@ import {
type ProviderSessionConfig,
type ProviderContent,
type ProviderTimelineItem,
} from "@getpaseo/plugin/provider";
} from "@getpaseo/plugin/server/provider";
import type {
AgentCapabilityFlags,
AgentClient,
@@ -2,7 +2,7 @@ import pino from "pino";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { describe, expect, test, vi } from "vitest";
import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider";
import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type {
@@ -48,7 +48,7 @@ import {
type AgentConfigurationValidationInput,
validateAgentConfigurationAgainstProvider,
} from "./agent-configuration-validator.js";
import type { ProviderRegistration } from "@getpaseo/plugin/provider";
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
import { PluginAgentClientRegistry } from "./plugin-provider.js";
const DEFAULT_REFRESH_TIMEOUT_MS = 120_000;
@@ -0,0 +1,126 @@
import { readFileSync, realpathSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import type { ImportKind } from "esbuild";
import type { Node, NamedImportBindings, NamedExportBindings } from "typescript";
const nodeRequire = createRequire(import.meta.url);
export type PluginImportKind = ImportKind | "type-reference";
interface ModuleImport {
specifier: string;
kind: PluginImportKind;
typeOnly: boolean;
}
// Keep the TypeScript compiler off daemon startup. esbuild erases these edges, so
// ownership validation needs the original syntax and declaration-aware resolution.
export function createPluginImportReader(directory: string) {
const ts = nodeRequire("typescript") as typeof import("typescript");
const configFile = ts.findConfigFile(directory, ts.sys.fileExists);
const config = configFile
? ts.getParsedCommandLineOfConfigFile(
configFile,
{},
{
...ts.sys,
onUnRecoverableConfigFileDiagnostic: (diagnostic) => {
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
},
},
)
: undefined;
const options = {
...config?.options,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
allowJs: true,
};
const cache = ts.createModuleResolutionCache(directory, (file) => file, options);
return {
resolve(specifier: string, importer: string, kind: PluginImportKind): string | undefined {
const resolved =
kind === "type-reference"
? ts.resolveTypeReferenceDirective(specifier, importer, options, ts.sys)
.resolvedTypeReferenceDirective?.resolvedFileName
: ts.resolveModuleName(
specifier,
importer,
options,
ts.sys,
cache,
undefined,
kind === "require-call" ? ts.ModuleKind.CommonJS : ts.ModuleKind.ESNext,
).resolvedModule?.resolvedFileName;
return resolved ? realpathSync.native(resolved) : undefined;
},
read(file: string): ModuleImport[] {
const source = ts.createSourceFile(
file,
readFileSync(file, "utf8"),
ts.ScriptTarget.Latest,
true,
);
const imports: ModuleImport[] = [];
function add(
node: Node | undefined,
typeOnly: boolean,
kind: ImportKind = "import-statement",
) {
if (node && ts.isStringLiteralLike(node)) {
imports.push({
specifier: node.text,
kind,
typeOnly: source.isDeclarationFile || typeOnly,
});
}
}
function onlyTypeBindings(
bindings: NamedImportBindings | NamedExportBindings | undefined,
): boolean {
return (
!!bindings &&
(ts.isNamedImports(bindings) || ts.isNamedExports(bindings)) &&
bindings.elements.length > 0 &&
bindings.elements.every((element) => element.isTypeOnly)
);
}
function visit(node: Node): void {
if (ts.isImportDeclaration(node)) {
const clause = node.importClause;
const typeOnly =
!!clause?.isTypeOnly || (!clause?.name && onlyTypeBindings(clause?.namedBindings));
add(node.moduleSpecifier, typeOnly);
} else if (ts.isExportDeclaration(node)) {
add(node.moduleSpecifier, node.isTypeOnly || onlyTypeBindings(node.exportClause));
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
add(node.argument.literal, true);
} else if (
ts.isImportEqualsDeclaration(node) &&
ts.isExternalModuleReference(node.moduleReference)
) {
add(node.moduleReference.expression, node.isTypeOnly, "require-call");
} else if (ts.isCallExpression(node)) {
if (node.expression.kind === ts.SyntaxKind.ImportKeyword)
add(node.arguments[0], false, "dynamic-import");
else if (ts.isIdentifier(node.expression) && node.expression.text === "require")
add(node.arguments[0], false, "require-call");
}
ts.forEachChild(node, visit);
}
visit(source);
for (const reference of source.referencedFiles) {
imports.push({
specifier: path.resolve(path.dirname(file), reference.fileName),
kind: "import-statement",
typeOnly: true,
});
}
for (const reference of source.typeReferenceDirectives) {
imports.push({ specifier: reference.fileName, kind: "type-reference", typeOnly: true });
}
return imports;
},
};
}
@@ -1,3 +1,4 @@
import { realpathSync } from "node:fs";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -80,6 +81,8 @@ async function createSplitPlugin(): Promise<{
client: string;
server: string;
}> {
// Keep the platform's original spelling (including Windows short names) in symlink
// targets. Only diagnostic expectations use canonical paths.
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
temporaryDirectories.push(directory);
await Promise.all([
@@ -126,7 +129,321 @@ export default function contribute(server) {
return { directory, client, server };
}
async function createRootAlias(directory: string): Promise<string> {
const aliases = await mkdtemp(path.join(tmpdir(), "paseo-plugin-root-alias-"));
temporaryDirectories.push(aliases);
const rootAlias = path.join(aliases, "plugin");
await symlink(directory, rootAlias, process.platform === "win32" ? "junction" : "dir");
return rootAlias;
}
describe("plugin runtime entries", () => {
it.each([
"react",
"react/jsx-runtime",
"react-native",
"@getpaseo/plugin/client",
"@getpaseo/plugin/client/ui",
])("rejects %s from server code", async (specifier) => {
const entries = await createSplitPlugin();
await writeFile(
entries.server,
`import * as value from "${specifier}"; export default function contribute() { return value; }`,
);
await expect(compilePlugin(entries)).rejects.toThrow("client-only module");
});
it.each([
"@getpaseo/plugin/server",
"@getpaseo/plugin/server/provider",
"@getpaseo/plugin/server/acp",
])("rejects %s from client code", async (specifier) => {
const entries = await createSplitPlugin();
await writeFile(
entries.client,
`import * as value from "${specifier}"; export default function contribute() { return value; }`,
);
await expect(compilePlugin(entries)).rejects.toThrow("server-only module");
});
it.each([
"react",
"node:fs",
"fs",
"@getpaseo/plugin/client",
"@getpaseo/plugin/server",
"../client/surface",
"../server/handler",
])("rejects %s from shared code", async (specifier) => {
const entries = await createSplitPlugin();
await writeFile(
path.join(entries.directory, "shared/labels.ts"),
`import * as value from "${specifier}"; export const clientLabel = value; export const serverLabel = value;`,
);
await expect(compilePlugin(entries)).rejects.toThrow("plugin shared");
});
it.each([
{ target: "client", dependency: "react" },
{ target: "server", dependency: "node:fs" },
{ target: "server", dependency: "@getpaseo/plugin/server/provider" },
] as const)(
"rejects a shared dependency reaching $dependency in the $target bundle",
async ({ target, dependency }) => {
const entries = await createSplitPlugin();
const dependencyDirectory = path.join(entries.directory, "node_modules/impure");
await mkdir(dependencyDirectory, { recursive: true });
await writeFile(
path.join(dependencyDirectory, "package.json"),
JSON.stringify({ name: "impure", main: "index.js" }),
);
await writeFile(path.join(dependencyDirectory, "index.js"), `export * from "${dependency}";`);
await writeFile(
path.join(entries.directory, "shared/labels.ts"),
`import * as value from "impure"; export const clientLabel = value; export const serverLabel = value;`,
);
await expect(
compilePlugin({
client: target === "client" ? entries.client : null,
server: target === "server" ? entries.server : null,
}),
).rejects.toThrow("plugin shared");
},
);
it.each([
'import type { PluginClientContext } from "@getpaseo/plugin/client"; export type Context = PluginClientContext;',
'export type { PluginClientContext } from "@getpaseo/plugin/client";',
'export type Context = import("@getpaseo/plugin/client").PluginClientContext;',
'import { type PluginClientContext } from "@getpaseo/plugin/client"; export type Context = PluginClientContext;',
'import type { ComponentType } from "react"; export type Component = ComponentType;',
])("rejects runtime-owned types in shared code: %s", async (typeSource) => {
const entries = await createSplitPlugin();
await writeFile(
path.join(entries.directory, "shared/labels.ts"),
typeSource + '\nexport const clientLabel = "client"; export const serverLabel = "server";',
);
await expect(compilePlugin(entries)).rejects.toThrow("plugin shared");
});
it.each([{ types: "./index.d.ts" }, { exports: { ".": { types: "./index.d.ts" } } }])(
"accepts declaration-only dependencies: %j",
async (manifest) => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules/neutral-types");
await mkdir(dependency, { recursive: true });
await writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "neutral-types", ...manifest }),
);
await writeFile(
path.join(dependency, "index.d.ts"),
"export type JsonValue = string | number | boolean | null;",
);
await writeFile(
path.join(entries.directory, "shared/labels.ts"),
'import type { JsonValue } from "neutral-types"; export const clientLabel: JsonValue = "client"; export const serverLabel: JsonValue = "server";',
);
const result = await compilePlugin(entries);
expect(result.clientBundle).toContain('"client"');
expect(result.serverBundle).toContain('"server"');
expect(result.clientBundle).not.toContain("neutral-types");
},
);
it("accepts a declaration import used only as a type without import type", async () => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules/neutral-types");
await mkdir(dependency, { recursive: true });
await writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "neutral-types", types: "./index.d.ts" }),
);
await writeFile(path.join(dependency, "index.d.ts"), "export type Value = string;");
await writeFile(
path.join(entries.directory, "shared/labels.ts"),
'import { Value } from "neutral-types"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";',
);
await expect(compilePlugin(entries)).resolves.toMatchObject({
clientBundle: expect.any(String),
serverBundle: expect.any(String),
});
await writeFile(
path.join(entries.directory, "shared/labels.ts"),
'import { Value } from "neutral-types"; export const clientLabel = Value; export const serverLabel = Value;',
);
await expect(compilePlugin(entries)).rejects.toThrow('Could not resolve "neutral-types"');
});
it("preserves guarded optional requires in server dependencies", async () => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules/optional-helper");
await mkdir(dependency, { recursive: true });
await writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "optional-helper", main: "index.js" }),
);
await writeFile(
path.join(dependency, "index.js"),
'let value = "fallback"; try { value = require("missing-accelerator"); } catch {} module.exports = value;',
);
await writeFile(entries.server, 'import value from "optional-helper"; export default value;');
await expect(compilePlugin({ client: null, server: entries.server })).resolves.toMatchObject({
serverBundle: expect.stringContaining("fallback"),
});
await writeFile(
path.join(dependency, "index.js"),
'module.exports = require("missing-accelerator");',
);
await expect(compilePlugin({ client: null, server: entries.server })).rejects.toThrow(
'Could not resolve "missing-accelerator"',
);
});
it.each(["path", "types"])(
"checks declaration reference %s directives",
async (referenceKind) => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules/typed-helper");
const referencedDirectory =
referenceKind === "path"
? dependency
: path.join(entries.directory, "node_modules/@types/referenced-helper");
await mkdir(dependency, { recursive: true });
await mkdir(referencedDirectory, { recursive: true });
await writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "typed-helper", types: "index.d.ts" }),
);
await writeFile(
path.join(dependency, "index.d.ts"),
`/// <reference ${referenceKind}="${referenceKind === "path" ? "./context.d.ts" : "referenced-helper"}" />
export type Value = string;`,
);
const referencedFile = path.join(
referencedDirectory,
referenceKind === "path" ? "context.d.ts" : "index.d.ts",
);
await writeFile(
referencedFile,
'export type { PluginClientContext } from "@getpaseo/plugin/client";',
);
await writeFile(
path.join(entries.directory, "shared/labels.ts"),
'import type { Value } from "typed-helper"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";',
);
await expect(compilePlugin(entries)).rejects.toThrow("plugin shared");
await writeFile(referencedFile, "export type Neutral = string;");
await expect(compilePlugin(entries)).resolves.toMatchObject({
clientBundle: expect.any(String),
serverBundle: expect.any(String),
});
},
);
it("checks the physical owner of type-only symlinks", async () => {
const entries = await createSplitPlugin();
await writeFile(
path.join(entries.directory, "server/context.ts"),
"export type Value = string;",
);
await symlink(
path.join(entries.directory, "server/context.ts"),
path.join(entries.directory, "shared/context.ts"),
);
await writeFile(
path.join(entries.directory, "shared/labels.ts"),
'import type { Value } from "./context"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";',
);
await expect(compilePlugin(entries)).rejects.toThrow("server-only module");
});
it("resolves type-only path aliases from the plugin tsconfig", async () => {
const entries = await createSplitPlugin();
await writeFile(
path.join(entries.directory, "tsconfig.json"),
JSON.stringify({
compilerOptions: { baseUrl: ".", paths: { "shared-types": ["shared/types.ts"] } },
}),
);
await writeFile(path.join(entries.directory, "shared/types.ts"), "export type Value = string;");
await writeFile(
path.join(entries.directory, "shared/labels.ts"),
'import type { Value } from "shared-types"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";',
);
await expect(compilePlugin(entries)).resolves.toMatchObject({
clientBundle: expect.any(String),
serverBundle: expect.any(String),
});
await writeFile(
path.join(entries.directory, "shared/types.ts"),
'export type { PluginClientContext as Value } from "@getpaseo/plugin/client";',
);
await expect(compilePlugin(entries)).rejects.toThrow("plugin shared");
});
it.each([
{ specifier: "@getpaseo/plugin/client", importKind: "import type" },
{ specifier: "@getpaseo/plugin/server", importKind: "import type" },
{ specifier: "@getpaseo/plugin/client", importKind: "import" },
{ specifier: "@getpaseo/plugin/server", importKind: "import" },
])(
"rejects transitive declaration dependencies on $specifier through $importKind",
async ({ specifier, importKind }) => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules/typed-helper");
await mkdir(dependency, { recursive: true });
await writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "typed-helper", main: "index.js", types: "index.d.ts" }),
);
await writeFile(path.join(dependency, "index.js"), "export {};");
await writeFile(
path.join(dependency, "index.d.ts"),
'export type { Context } from "./context";',
);
const context = specifier.endsWith("client") ? "PluginClientContext" : "PluginServerContext";
await writeFile(
path.join(dependency, "context.d.ts"),
`export type { ${context} as Context } from "${specifier}";`,
);
await writeFile(
path.join(entries.directory, "shared/labels.ts"),
`${importKind} { Context } from "typed-helper"; export type Value = Context; export const clientLabel = "client"; export const serverLabel = "server";`,
);
await expect(compilePlugin(entries)).rejects.toThrow("plugin shared");
},
);
it("checks files reached only through type imports", async () => {
const entries = await createSplitPlugin();
await writeFile(
path.join(entries.directory, "shared/types.ts"),
'export type { PluginClientContext } from "@getpaseo/plugin/client";',
);
await writeFile(
path.join(entries.directory, "shared/labels.ts"),
'export type { PluginClientContext } from "./types"; export const clientLabel = "client"; export const serverLabel = "server";',
);
await expect(compilePlugin(entries)).rejects.toThrow("plugin shared");
});
it.each([
"@paseo/plugin",
"@getpaseo/plugin/react-native",
"@getpaseo/plugin/ui",
"@getpaseo/plugin/provider",
"@getpaseo/plugin/acp",
"@getpaseo/plugin/host",
])("rejects retired entry %s", async (specifier) => {
const entries = await createSplitPlugin();
await writeFile(
entries.server,
`import * as sdk from "${specifier}"; export default function contribute() { return sdk; }`,
);
await expect(compilePlugin(entries)).rejects.toThrow(specifier);
});
it("builds each runtime from its own entry and shares neutral modules", async () => {
const entries = await createSplitPlugin();
@@ -205,7 +522,7 @@ export default function contribute() { void Surface; return () => undefined; }`,
export default function contribute() { void value; return () => undefined; }`,
);
await expect(compilePlugin(entries)).rejects.toThrow(
`Plugin modules belong in client/, server/, or shared/: ${path.join(entries.directory, "helper")}`,
`Plugin modules belong in client/, server/, or shared/: ${path.join(realpathSync.native(entries.directory), "helper")}`,
);
});
@@ -225,7 +542,7 @@ export default function contribute() { void secret; return () => undefined; }`,
]);
await expect(compilePlugin({ client: null, server })).rejects.toThrow(
`Plugin modules belong in client/, server/, or shared/: ${path.join(parent, "secret")}`,
`Plugin modules belong in client/, server/, or shared/: ${path.join(realpathSync.native(parent), "secret")}`,
);
});
@@ -242,7 +559,7 @@ export default function contribute() { void secret; return () => undefined; }`,
);
await expect(compilePlugin(entries)).rejects.toThrow(
`Plugin modules belong in client/, server/, or shared/: ${outside}`,
`Plugin modules belong in client/, server/, or shared/: ${realpathSync.native(outside)}`,
);
});
@@ -263,7 +580,7 @@ export default function contribute() { void secret; return () => undefined; }`,
]);
await expect(compilePlugin({ client: null, server })).rejects.toThrow(
`Plugin modules belong in client/, server/, or shared/: ${path.join(parent, "node_modules", "secret")}`,
`Plugin modules belong in client/, server/, or shared/: ${path.join(realpathSync.native(parent), "node_modules", "secret")}`,
);
});
@@ -363,10 +680,69 @@ export default function contribute() { void handler; return () => undefined; }`,
);
});
it("classifies symlinks by their canonical target", async () => {
it.each(["file", "directory"])(
"preserves authored ownership through root and %s aliases",
async (aliasKind) => {
const entries = await createSplitPlugin();
const rootAlias = await createRootAlias(entries.directory);
const sharedValue = path.join(entries.directory, "shared/value.ts");
await writeFile(sharedValue, 'export const value = "shared";');
if (aliasKind === "file") {
await symlink(sharedValue, path.join(entries.directory, "server/alias.ts"));
} else {
await rm(path.join(entries.directory, "server"), { recursive: true });
await symlink(
path.join(entries.directory, "shared"),
path.join(entries.directory, "server"),
process.platform === "win32" ? "junction" : "dir",
);
}
await writeFile(
entries.client,
`export { value } from ${JSON.stringify(path.join(rootAlias, "server", aliasKind === "file" ? "alias.ts" : "value.ts"))};`,
);
await expect(compilePlugin({ client: entries.client, server: null })).rejects.toThrow(
"server-only module",
);
await writeFile(
entries.client,
`export { value } from ${JSON.stringify(path.join(rootAlias, "shared/value.ts"))};`,
);
await expect(compilePlugin({ client: entries.client, server: null })).resolves.toMatchObject({
clientBundle: expect.stringContaining("shared"),
});
},
);
it("rejects generated React imports in dependencies reached through shared symlinks", async () => {
const entries = await createSplitPlugin();
const rootAlias = await createRootAlias(entries.directory);
const dependency = path.join(entries.directory, "node_modules/decoration");
await mkdir(dependency, { recursive: true });
await writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "decoration", main: "index.tsx" }),
);
await writeFile(path.join(dependency, "index.tsx"), "export const decoration = <></>;");
await writeFile(
path.join(entries.directory, "shared/decoration.ts"),
'export { decoration } from "decoration";',
);
await symlink(
path.join(rootAlias, "shared/decoration.ts"),
path.join(entries.directory, "client/decoration.ts"),
);
await writeFile(entries.client, 'export { decoration } from "./client/decoration";');
await expect(compilePlugin({ client: entries.client, server: null })).rejects.toThrow(
"plugin shared",
);
});
it.each([false, true])("classifies symlink targets through a root alias: %s", async (aliased) => {
const entries = await createSplitPlugin();
const targetRoot = aliased ? await createRootAlias(entries.directory) : entries.directory;
const link = path.join(entries.directory, "client", "handler.ts");
await symlink(path.join(entries.directory, "server", "handler.ts"), link);
await symlink(path.join(targetRoot, "server", "handler.ts"), link);
await writeFile(
entries.client,
`import { handler } from "./client/handler";
@@ -432,33 +808,37 @@ export default function contribute() { void value; return () => undefined; }`,
expect(serverBundle).toContain("linked dependency");
});
it("does not treat the plugin package manifest as a linked dependency root", async () => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules", "fixture-dependency");
const secret = path.join(entries.directory, "secret.ts");
await mkdir(dependency, { recursive: true });
await Promise.all([
writeFile(
path.join(entries.directory, "package.json"),
JSON.stringify({ name: "fixture-dependency" }),
),
writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "fixture-dependency", main: "index.js" }),
),
writeFile(secret, `export const secret = "plugin root";`),
symlink(secret, path.join(dependency, "index.js")),
writeFile(
entries.client,
`import { secret } from "fixture-dependency";
it.each([false, true])(
"rejects plugin manifests as linked roots through an alias: %s",
async (aliased) => {
const entries = await createSplitPlugin();
const targetRoot = aliased ? await createRootAlias(entries.directory) : entries.directory;
const dependency = path.join(entries.directory, "node_modules", "fixture-dependency");
const secret = path.join(entries.directory, "secret.ts");
await mkdir(dependency, { recursive: true });
await Promise.all([
writeFile(
path.join(entries.directory, "package.json"),
JSON.stringify({ name: "fixture-dependency" }),
),
writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "fixture-dependency", main: "index.js" }),
),
writeFile(secret, `export const secret = "plugin root";`),
symlink(path.join(targetRoot, "secret.ts"), path.join(dependency, "index.js")),
writeFile(
entries.client,
`import { secret } from "fixture-dependency";
export default function contribute() { void secret; return () => undefined; }`,
),
]);
),
]);
await expect(compilePlugin(entries)).rejects.toThrow(
`Plugin modules belong in client/, server/, or shared/: ${secret}`,
);
});
await expect(compilePlugin(entries)).rejects.toThrow(
`Plugin modules belong in client/, server/, or shared/: ${realpathSync.native(secret)}`,
);
},
);
it("does not let remembered linked roots hide plugin-local runtime boundaries", async () => {
const entries = await createSplitPlugin();
+198 -86
View File
@@ -1,9 +1,11 @@
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { createRequire, isBuiltin } from "node:module";
import path from "node:path";
import type { OnResolveResult, Plugin } from "esbuild";
import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js";
import type { Metafile, OnResolveResult, Plugin } from "esbuild";
import {
PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
isPluginClientOnlySdkSpecifier,
isPluginServerOnlySdkSpecifier,
PLUGIN_SDK_SPECIFIERS,
} from "./plugin-sdk-specifiers.js";
@@ -100,12 +102,13 @@ function findDependencyRoot(
pluginDirectory: string,
): string | null {
const expectedName = dependencyName(specifier);
const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`;
let directory = path.dirname(resolvedPath);
for (;;) {
const manifestPath = path.join(directory, "package.json");
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown };
if (manifest.name === expectedName) {
if (manifest.name === expectedName || manifest.name === ambientName) {
if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) {
return null;
}
@@ -120,7 +123,7 @@ function findDependencyRoot(
function moduleBoundaryError(
moduleLocation: PluginModuleLocation | null,
target: PluginBuildTarget,
target: PluginBuildTarget | "shared",
filePath: string,
): OnResolveResult | null {
if (moduleLocation === "invalid") {
@@ -144,12 +147,22 @@ function lexicalBoundaryError(
specifier: string,
resolveDirectory: string,
pluginDirectory: string,
target: PluginBuildTarget,
target: PluginBuildTarget | "shared",
): OnResolveResult | null {
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null;
const lexicalPath = path.resolve(resolveDirectory, specifier);
if (!containsPath(pluginDirectory, lexicalPath)) return null;
return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath);
if (containsPath(pluginDirectory, lexicalPath)) {
return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath);
}
// Normalize only a containing root alias. Resolving the whole import would erase
// an authored server/ or client/ location when the final file is a symlink.
for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) {
if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) {
const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath));
return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath);
}
if (path.dirname(ancestor) === ancestor) return null;
}
}
function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin {
@@ -158,20 +171,115 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory:
return {
name: `paseo-plugin-${target}-runtime-boundary`,
setup(buildContext) {
const checked = new Set<string>();
const imports = createPluginImportReader(pluginDirectory);
function resolvedBoundaryError(
resolvedFile: string,
specifier: string,
importer: string,
owner: PluginBuildTarget | "shared",
) {
const file = realpathSync.native(resolvedFile);
const location = directoryTarget(file, pluginDirectory);
if (location === "invalid") {
if (
[...linkedDependencyRoots].some(
(root) => containsPath(root, importer) && containsPath(root, file),
)
)
return null;
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) {
const root = findDependencyRoot(file, specifier, pluginDirectory);
if (root) {
linkedDependencyRoots.add(root);
return null;
}
}
}
return moduleBoundaryError(location, owner, file);
}
async function resolveImportFiles(
file: string,
specifier: string,
kind: PluginImportKind,
typeOnly: boolean,
): Promise<Set<string>> {
const declaration = imports.resolve(specifier, file, kind);
const dependencyFiles = new Set<string>();
if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration)))
dependencyFiles.add(declaration);
if (typeOnly && !declaration) {
throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`);
}
if (!typeOnly && kind !== "type-reference") {
const resolution = await buildContext.resolve(specifier, {
importer: file,
resolveDir: path.dirname(file),
kind,
});
// esbuild owns missing-runtime errors: erased imports and guarded optional
// requires are legal. Inspect every dependency that actually resolves.
if (!resolution.errors.length && !resolution.external && resolution.namespace === "file")
dependencyFiles.add(realpathSync.native(resolution.path));
}
return dependencyFiles;
}
async function checkSourceImports(
sourcePath: string,
inheritedOwner: PluginBuildTarget | "shared",
): Promise<OnResolveResult | null> {
const file = realpathSync.native(sourcePath);
const owner =
directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner;
const key = `${owner}:${file}`;
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
checked.add(key);
for (const { specifier, kind, typeOnly } of imports.read(file)) {
const packageSpecifier =
kind === "type-reference"
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
: specifier;
const error =
runtimeSpecifierError(specifier, owner, file) ??
runtimeSpecifierError(packageSpecifier, owner, file) ??
lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner);
if (error) return error;
// Host modules have separately enforced SDK boundaries and need no local installation.
if (
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
isBuiltin(specifier) ||
packageSpecifier === "@types/node"
)
continue;
const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly);
for (const dependencyFile of dependencyFiles) {
const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner);
if (boundaryError) return boundaryError;
const dependencyError = await checkSourceImports(dependencyFile, owner);
if (dependencyError) return dependencyError;
}
}
return null;
}
buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) =>
checkSourceImports(args.path, target),
);
buildContext.onResolve({ filter: /.*/ }, async (args) => {
if (args.kind === "entry-point") return null;
if (args.pluginData === boundaryResolution) return null;
const lexicalError = lexicalBoundaryError(
args.path,
args.resolveDir,
pluginDirectory,
target,
);
const importer =
args.namespace === "file" ? realpathSync.native(args.importer) : args.importer;
const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir;
const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target;
const specifierError = runtimeSpecifierError(args.path, owner, importer);
if (specifierError) return specifierError;
const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner);
if (lexicalError) return lexicalError;
const resolution = await buildContext.resolve(args.path, {
importer: args.importer,
importer,
namespace: args.namespace,
resolveDir: args.resolveDir,
resolveDir,
kind: args.kind,
pluginData: boundaryResolution,
with: args.with,
@@ -183,26 +291,7 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory:
) {
return null;
}
const resolvedPath = resolution.path;
const importedTarget = directoryTarget(resolvedPath, pluginDirectory);
if (importedTarget === "invalid") {
if (
[...linkedDependencyRoots].some(
(root) => containsPath(root, args.importer) && containsPath(root, resolvedPath),
)
) {
return null;
}
if (!args.path.startsWith(".") && !path.isAbsolute(args.path)) {
const dependencyRoot = findDependencyRoot(resolvedPath, args.path, pluginDirectory);
if (dependencyRoot) {
linkedDependencyRoots.add(dependencyRoot);
return null;
}
}
return moduleBoundaryError(importedTarget, target, resolvedPath);
}
return moduleBoundaryError(importedTarget, target, resolvedPath);
return resolvedBoundaryError(resolution.path, args.path, importer, owner);
});
},
};
@@ -219,60 +308,86 @@ function makeHermesInteropEager(code: string): string {
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
function exactSpecifierFilter(specifiers: readonly string[]): RegExp {
const alternatives = specifiers.map((specifier) =>
specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
return new RegExp(`^(${alternatives.join("|")})$`);
}
function createUnusedPlatformModulePlugin(): Plugin {
const filter = exactSpecifierFilter([
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
]);
return {
name: "paseo-plugin-server-unused-platform-modules",
setup(buildContext) {
buildContext.onResolve({ filter }, (args) => ({
path: args.path,
namespace: "paseo-unused-platform-module",
sideEffects: false,
}));
buildContext.onLoad({ filter: /.*/, namespace: "paseo-unused-platform-module" }, () => ({
contents: "module.exports = {};",
loader: "js",
}));
},
};
}
function createClientNodeImportPlugin(): Plugin {
return {
name: "paseo-plugin-client-node-imports",
setup(buildContext) {
buildContext.onResolve({ filter: /^node:/ }, (args) => ({
function runtimeSpecifierError(
specifier: string,
target: PluginBuildTarget | "shared",
importer: string,
): OnResolveResult | null {
let kind: string | null = null;
if (specifier === "@getpaseo/plugin/client/host") kind = "host-private";
else if (
(specifier === "@getpaseo/plugin" ||
specifier.startsWith("@getpaseo/plugin/") ||
specifier === "@paseo/plugin" ||
specifier.startsWith("@paseo/plugin/")) &&
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
)
kind = "Unknown SDK";
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
kind = "Node";
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
else if (
target !== "client" &&
(isPluginClientOnlySdkSpecifier(specifier) ||
/^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test(
specifier,
))
)
kind = "client-only";
return kind
? {
errors: [
{
text: `Node module cannot be imported into the plugin client bundle: ${args.path} imported by ${args.importer}`,
text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`,
},
],
}));
},
};
}
: null;
}
function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void {
function inputLocation(file: string): PluginModuleLocation | null {
const absolutePath = path.resolve(file);
// Metafile keys must stay unchanged for graph traversal. Only filesystem
// inputs have canonical paths; data URLs and external specifiers do not.
return directoryTarget(
existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath,
pluginDirectory,
);
}
const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared");
const visited = new Set<string>();
while (pending.length) {
const file = pending.pop()!;
if (visited.has(file)) continue;
visited.add(file);
for (const dependency of inputs[file]?.imports ?? []) {
const location = dependency.external ? null : inputLocation(dependency.path);
const error =
runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ??
(dependency.external
? null
: moduleBoundaryError(
location === "invalid" ? null : location,
"shared",
dependency.path,
));
if (error?.errors?.length) throw new Error(error.errors[0].text);
if (!dependency.external) pending.push(dependency.path);
}
}
}
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const { build } = loadEsbuild();
const pluginDirectory = path.dirname(entryPath);
// Use native canonical paths throughout: TypeScript expands Windows short names
// when resolving type references, while the JS realpath implementation retains them.
const pluginDirectory = realpathSync.native(path.dirname(entryPath));
const result = await build({
entryPoints: [entryPath],
entryPoints: [realpathSync.native(entryPath)],
bundle: true,
format: "cjs",
jsx: target === "client" ? "automatic" : undefined,
jsx: "automatic",
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
@@ -289,16 +404,13 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
"zod",
]
: [...PLUGIN_SDK_SPECIFIERS, "zod"],
plugins: [
createRuntimeBoundaryPlugin(target, pluginDirectory),
...(target === "client"
? [createClientNodeImportPlugin()]
: [createUnusedPlatformModulePlugin()]),
],
plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)],
metafile: true,
logLevel: "silent",
treeShaking: true,
write: false,
});
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
const output = result.outputFiles[0]?.text;
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
return wrapCommonJsBundle(makeHermesInteropEager(output));
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginLifecycle } from "./lifecycle/index.js";
import path from "node:path";
import { stat, rm } from "node:fs/promises";
import type pino from "pino";
import type { ProviderRegistration } from "@getpaseo/plugin/provider";
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
import {
PluginIdSchema,
type PluginLogEntry,
@@ -26,7 +26,8 @@ test("plugin handlers create workspaces and agents through their Paseo API", asy
);
await writeFile(
path.join(pluginDirectory, "index.server.ts"),
`import { defineRpc, type PluginServerContext } from "@getpaseo/plugin";
`import { defineRpc } from "@getpaseo/plugin";
import { type PluginServerContext } from "@getpaseo/plugin/server";
import { z } from "zod";
const create = defineRpc({
@@ -3,8 +3,8 @@ import type {
ProviderCatalogOptions,
ProviderEvent,
ProviderInput,
} from "@getpaseo/plugin/provider";
import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/provider";
} from "@getpaseo/plugin/server/provider";
import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/server/provider";
import { z } from "zod";
export interface PluginProviderMetadata {
@@ -0,0 +1,39 @@
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import { expect, it } from "vitest";
it("keeps React out of the plugin host's runtime dependency graph", async () => {
await expect(
build({
entryPoints: [
fileURLToPath(new URL("./plugin-process.ts", import.meta.url)),
"@getpaseo/plugin",
"@getpaseo/plugin/server",
"@getpaseo/plugin/server/provider",
"@getpaseo/plugin/server/acp",
],
outdir: "unused",
conditions: ["source"],
bundle: true,
platform: "node",
format: "esm",
// Node evaluates re-exports even when the host only imports one helper.
treeShaking: false,
write: false,
logLevel: "silent",
plugins: [
{
name: "no-react",
setup(context) {
context.onResolve(
{ filter: /^(react|react-dom|react-native|use-sync-external-store)(\/|$)/ },
({ path, importer }) => ({
errors: [{ text: `React dependency ${path} imported by ${importer}` }],
}),
);
},
},
],
}),
).resolves.toMatchObject({ errors: [] });
});
@@ -5,27 +5,20 @@ import {
type PluginProcessRequest,
} from "./plugin-process-protocol.js";
import { createRequire } from "node:module";
import {
defineSettings,
type SettingsDefinition,
defineAttachmentSource,
defineRpc,
type PluginRpcContract,
} from "@getpaseo/plugin";
import * as pluginSharedRuntime from "@getpaseo/plugin";
import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider";
import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp";
import type { SettingsDefinition, PluginRpcContract } from "@getpaseo/plugin";
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
import {
ProviderEventSchema,
type ProviderConnection,
type ProviderRegistration,
} from "@getpaseo/plugin/provider";
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
} from "@getpaseo/plugin/server/provider";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createPluginDaemonTransportFactory } from "./daemon-transport.js";
import {
isPluginClientOnlySdkSpecifier,
isPluginSdkSpecifier,
isPluginServerTypesSdkSpecifier,
} from "./plugin-sdk-specifiers.js";
import { isPluginClientOnlySdkSpecifier } from "./plugin-sdk-specifiers.js";
import { createPluginClientId } from "./plugin-session-identity.js";
import { PluginSettingsStore } from "./settings/index.js";
@@ -215,21 +208,16 @@ async function closeProviderConnection(connectionId: string): Promise<void> {
send({ type: "provider.closed", connectionId });
}
const pluginAuthorRuntime = {
defineAttachmentSource,
defineSettings,
defineRpc,
Icon() {
throw new Error("Icon is available only in plugin client code");
},
};
function runtimeRequire(name: string): unknown {
if (isPluginClientOnlySdkSpecifier(name)) {
throw new Error(`${name} is available only in plugin client code`);
}
if (isPluginServerTypesSdkSpecifier(name)) return {};
if (isPluginSdkSpecifier(name)) return pluginAuthorRuntime;
if (name === "@getpaseo/plugin") return pluginSharedRuntime;
if (name === "@getpaseo/plugin/server") return {};
if (name === "@getpaseo/plugin/server/provider") return pluginProviderRuntime;
if (name === "@getpaseo/plugin/server/acp") return pluginAcpRuntime;
if (name === "@getpaseo/plugin/client/host")
throw new Error(`${name} is private to the app host`);
return nodeRequire(name);
}
@@ -1,36 +1,26 @@
// Specifiers a plugin author can import to reach the SDK. The daemon never resolves these
// from disk: the compiler marks them external and the plugin subprocess hands back its own
// runtime, so a plugin typechecks against generated declarations without installing anything.
//
// COMPAT(plugin-sdk-scope): @paseo/plugin was the SDK name through 0.5.0-beta.1 and was never
// published — that scope is not ours. Plugins scaffolded against that name still import it, so
// both spellings resolve. Remove the @paseo/* entries after 2026-11-19.
// These entries are supplied by the host and remain external in author bundles.
export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [
"@getpaseo/plugin/ui",
"@getpaseo/plugin/react-native",
"@paseo/plugin/react-native",
"@getpaseo/plugin/client",
"@getpaseo/plugin/client/ui",
"@getpaseo/plugin/client/react-native",
] as const;
export const PLUGIN_SERVER_TYPES_SDK_SPECIFIERS = [
const PLUGIN_SERVER_ONLY_SDK_SPECIFIERS = [
"@getpaseo/plugin/server",
"@paseo/plugin/server",
"@getpaseo/plugin/server/provider",
"@getpaseo/plugin/server/acp",
] as const;
export const PLUGIN_SDK_SPECIFIERS = [
"@getpaseo/plugin",
"@paseo/plugin",
...PLUGIN_SERVER_TYPES_SDK_SPECIFIERS,
...PLUGIN_SERVER_ONLY_SDK_SPECIFIERS,
...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
] as const;
export function isPluginSdkSpecifier(name: string): boolean {
return (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(name);
}
export function isPluginClientOnlySdkSpecifier(name: string): boolean {
return (PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS as readonly string[]).includes(name);
}
export function isPluginServerTypesSdkSpecifier(name: string): boolean {
return (PLUGIN_SERVER_TYPES_SDK_SPECIFIERS as readonly string[]).includes(name);
export function isPluginServerOnlySdkSpecifier(name: string): boolean {
return (PLUGIN_SERVER_ONLY_SDK_SPECIFIERS as readonly string[]).includes(name);
}
@@ -1,10 +1,11 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { fork } from "node:child_process";
import { tmpdir } from "node:os";
import { PassThrough } from "node:stream";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pino from "pino";
import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider";
import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentStreamEvent } from "../agent/agent-sdk-types.js";
import { PluginAgentClientRegistry } from "../agent/plugin-provider.js";
@@ -207,6 +208,65 @@ afterEach(async () => {
});
describe("PluginRuntime", () => {
it.each([
{ specifier: "@getpaseo/plugin", moduleDirectory: "shared" },
{ specifier: "@getpaseo/plugin", moduleDirectory: "server" },
])(
"loads $specifier contracts without React in the subprocess module graph",
async ({ specifier, moduleDirectory }) => {
const directory = await createPlugin(
"react-free",
`import { rpc } from "./${moduleDirectory}/contract";
export default function contribute(server) {
server.handle(rpc, (input) => input);
return () => {};
}`,
);
await mkdir(path.join(directory, moduleDirectory));
await writeFile(
path.join(directory, moduleDirectory, "contract.ts"),
`import { defineRpc, defineAttachmentSource } from "${specifier}";
import { z } from "zod";
export const rpc = defineRpc({ name: "echo", input: z.string(), output: z.string() });
const source = defineAttachmentSource({ id: "test", search: rpc });
if (source.search !== rpc) throw new Error("Attachment contract was not preserved");`,
);
// Reject imports even when the workspace has React installed. This covers the host's
// static graph and SDK imports evaluated through the compiled plugin's runtimeRequire.
const guard = `export function resolve(specifier, context, nextResolve) {
if (/^(react|react-dom|react-native|use-sync-external-store)(\\/|$)/.test(specifier)) {
throw new Error("React module reached plugin subprocess: " + specifier);
}
return nextResolve(specifier, context);
}`;
const guardUrl = `data:text/javascript,${encodeURIComponent(guard)}`;
const loaderUrl = new URL("../../terminal/terminal-ts-loader.mjs", import.meta.url).href;
const setup = `import { register } from "node:module";
register(${JSON.stringify(loaderUrl)});
register(${JSON.stringify(guardUrl)});`;
const runtime = createTestRuntime({
spawnChild: () =>
fork(new URL("./plugin-process.ts", import.meta.url), [], {
execArgv: [
"--experimental-strip-types",
"--import",
`data:text/javascript,${encodeURIComponent(setup)}`,
],
serialization: "advanced",
stdio: ["ignore", "pipe", "pipe", "ipc"],
}),
});
try {
await runtime.startPlugin("react-free", directory);
await expect(runtime.invoke("react-free", "echo", "hello")).resolves.toBe("hello");
} catch (error) {
throw new Error(JSON.stringify(runtime.getLogs("react-free")), { cause: error });
} finally {
await runtime.stopAll();
}
},
);
it("runs the direct example through the existing AgentClient path", async () => {
const pluginId = "provider-direct-example";
const directory = fileURLToPath(
@@ -253,8 +313,8 @@ describe("PluginRuntime", () => {
it("runs a provider connection through the real plugin subprocess boundary", async () => {
const directory = await createPlugin(
"provider-round-trip",
`import type { PluginServerContext } from "@getpaseo/plugin";
import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider";
`import type { PluginServerContext } from "@getpaseo/plugin/server";
import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider";
const provider: ProviderRegistration = {
id: "direct-example",
@@ -461,8 +521,8 @@ export default function contribute(server: PluginServerContext) {
);
const directory = await createPlugin(
"provider-acp-round-trip",
`import type { PluginServerContext } from "@getpaseo/plugin";
import { runAcpProvider } from "@getpaseo/plugin/acp";
`import type { PluginServerContext } from "@getpaseo/plugin/server";
import { runAcpProvider } from "@getpaseo/plugin/server/acp";
import { vendorEditTransformer } from "./server/vendor-edit.js";
export default function contribute(server: PluginServerContext) {
@@ -598,7 +658,7 @@ lines.on("line", (line) => {
it("rejects a malformed provider event from a real plugin subprocess", async () => {
const directory = await createPlugin(
"malicious-provider",
`import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider";
`import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider";
let connectionId = "";
process.on("message", (message: unknown) => {
const value = message as { type?: string; connectionId?: string };
@@ -665,7 +725,7 @@ export default function contribute(server: any) { server.registerProvider(provid
it("emits runtime failure for live sessions when a real plugin process dies", async () => {
const directory = await createPlugin(
"dying-provider",
`import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider";
`import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider";
const provider: ProviderRegistration = {
id: "dying",
label: "Dying",
@@ -966,7 +1026,7 @@ export default function contribute(plugin: unknown) {
const directory = await createPlugin(
"shutdown-connect",
`import { writeFile } from "node:fs/promises";
import type { ProviderRegistration } from "@getpaseo/plugin/provider";
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
const provider: ProviderRegistration = {
id: "delayed",
@@ -1449,34 +1509,6 @@ export default function contribute(server: any) {
await runtime.stopAll();
});
// COMPAT(plugin-sdk-scope): plugins scaffolded through 0.5.0-beta.1 import the unpublished
// @paseo/plugin name. Drop with the specifiers in plugin-sdk-specifiers.ts.
it("loads a plugin that imports the pre-rename @paseo/plugin specifier", async () => {
const directory = await createPlugin(
"legacy-sdk",
`import { z } from "zod";
import { defineRpc } from "@paseo/plugin";
const pingRpc = defineRpc({
name: "ping",
input: z.object({}),
output: z.object({ ok: z.boolean() }),
});
export default function contribute(plugin: any) {
plugin.handle(pingRpc, async () => ({ ok: true }));
return () => undefined;
}`,
);
const runtime = createTestRuntime();
await runtime.startPlugin("legacy-sdk", directory);
await expect(runtime.invoke("legacy-sdk", "ping", {})).resolves.toMatchObject({ ok: true });
await runtime.stopAll();
});
it("keeps client and server modules in their target runtime", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
@@ -1493,7 +1525,7 @@ export default function contribute(plugin: any) {
),
writeFile(
path.join(directory, "index.client.tsx"),
`import type { PluginClientContext } from "@getpaseo/plugin";
`import type { PluginClientContext } from "@getpaseo/plugin/client";
import { Surface } from "./client/surface";
export default function contribute(client: PluginClientContext) {
client.addSurface("main", Surface);
@@ -1503,7 +1535,7 @@ export default function contribute(client: PluginClientContext) {
),
writeFile(
path.join(directory, "index.server.ts"),
`import type { PluginServerContext } from "@getpaseo/plugin";
`import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inspectRpc } from "./shared/inspect";
import { inspectHost } from "./server/inspect";
export default function contribute(server: PluginServerContext) {
@@ -1576,7 +1608,7 @@ export function inspectHost(_input: z.input<typeof inspectRpc.input>) {
),
writeFile(
path.join(directory, "index.client.tsx"),
`import type { PluginClientContext } from "@getpaseo/plugin";
`import type { PluginClientContext } from "@getpaseo/plugin/client";
import { Surface } from "./client/surface";
export default function contribute(client: PluginClientContext) {
@@ -1621,7 +1653,7 @@ export function Surface() { return readSecret(); }`,
),
writeFile(
path.join(directory, "index.server.ts"),
`import type { PluginServerContext } from "@getpaseo/plugin";
`import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inspect } from "./server/inspect";
import { inspectRpc } from "./shared/inspect";
@@ -14,7 +14,7 @@ import {
type ProviderConnection,
type ProviderEvent,
type ProviderInput,
} from "@getpaseo/plugin/provider";
} from "@getpaseo/plugin/server/provider";
import type { PluginLogEntry } from "@getpaseo/protocol/messages";
import { compilePlugin } from "./compiler.js";
import { readPluginManifest } from "./manifest.js";
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { z } from "zod";
import { settingsRpc } from "@getpaseo/plugin/host";
import { settingsRpc } from "@getpaseo/plugin";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -3,7 +3,7 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { defineSettings, type SettingsDefinition } from "@getpaseo/plugin";
import { settingsRpc } from "@getpaseo/plugin/host";
import { settingsRpc } from "@getpaseo/plugin";
const envelopeSchema = z.object({ version: z.number().int().positive(), values: z.json() });
function message(error: unknown): string {
@@ -1,4 +1,4 @@
import type { PluginServerContext } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
export default function contribute(server: PluginServerContext) {
server.before("agent.create", ({ request }) => {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PluginClientContext } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
export default function contribute(client: PluginClientContext) {
client.addTheme({
@@ -1,5 +1,5 @@
import type { PluginTimelineItemProps } from "@getpaseo/plugin";
import { useRevealedText } from "@getpaseo/plugin/react-native";
import type { PluginTimelineItemProps } from "@getpaseo/plugin/client";
import { useRevealedText } from "@getpaseo/plugin/client/react-native";
import { useMemo } from "react";
import { Text } from "react-native";
import { z } from "zod";
@@ -1,4 +1,4 @@
import type { PluginClientContext } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { InlineThinking, inlineThinkingSchema } from "./client/thinking";
export default function contribute(client: PluginClientContext) {
@@ -1,4 +1,4 @@
import type { PluginServerContext } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { latestOutputText, shellCommand } from "./server/inspect";
export default function contribute(server: PluginServerContext) {
@@ -1,4 +1,4 @@
import type { PluginServerContext } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { logHook } from "./server/log";
export default function contribute(server: PluginServerContext) {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PluginClientContext } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { issueAttachments } from "./shared/issues";
export default function contribute(client: PluginClientContext) {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PluginServerContext } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { searchIssues } from "./server/issues";
import { searchIssuesRpc } from "./shared/issues";
+2 -2
View File
@@ -1,13 +1,13 @@
import { useMutation } from "@tanstack/react-query";
import { Icon } from "@getpaseo/plugin/client/react-native";
import {
Icon,
type PluginClientContext,
type PluginComposerPillProps,
type PluginWorkspacePanelProps,
useAgent,
useRpc,
useWorkspace,
} from "@getpaseo/plugin";
} from "@getpaseo/plugin/client";
import { useCallback, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { incrementRpc } from "../shared/increment";
@@ -1,4 +1,4 @@
import type { PluginClientContext } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { contributeClient, ExamplePanel } from "./client/main";
export default function contribute(client: PluginClientContext) {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PluginServerContext } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { increment } from "./server/increment";
import { incrementRpc } from "./shared/increment";
+2 -2
View File
@@ -1,4 +1,4 @@
import type { PluginSurfaceProps } from "@getpaseo/plugin";
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
import {
Modal,
ScrollView,
@@ -6,7 +6,7 @@ import {
TextInput,
copyText,
useToast,
} from "@getpaseo/plugin/react-native";
} from "@getpaseo/plugin/client/react-native";
import { useCallback, useMemo, useRef, useState } from "react";
import {
Pressable,
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PluginClientContext } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { ModalExamples } from "./client/examples";
export default function contribute(plugin: PluginClientContext) {
@@ -1,5 +1,5 @@
import type { PluginServerContext } from "@getpaseo/plugin";
import { runAcpProvider } from "@getpaseo/plugin/acp";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { runAcpProvider } from "@getpaseo/plugin/server/acp";
import { vendorEditTransformer } from "./server/vendor-edit.js";
export default function contribute(server: PluginServerContext) {

Some files were not shown because too many files have changed in this diff Show More