From 53d824a320ccacecb08583be3cffc61ed2974576 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 2 Sep 2026 19:11:45 +0200 Subject: [PATCH] feat(plugins): split client and server plugin entries (#4206) * feat(plugins): phase 1 split runtime entries after green gate The compiler now builds explicit client and server entries, so runtime boundaries are source-owned instead of registration-name filtering. Compiler and runtime tests prove the entry split, directory and suffix boundaries, client Node import errors, migration failure, and client-only operation without a subprocess. * feat(plugins): phase 2 run client entries after green gate Explicit client and server contexts replace the mixed plugin context. The app runner owns every client registration and its idempotent removal, including late contributions and composer pills; focused contribution tests prove the removal contract, and all five migrated examples reached running on the isolated worktree daemon. * feat(cli): phase 3 scaffold runtime split after green gate The generated project demonstrates the required shared RPC contract, server handler, client surface, and sidebar wiring. The scaffold test and an isolated init, typecheck, install, and running check prove the phase acceptance path. * docs(plugins): phase 4 publish migration after green gate Gate: current docs and the paseo-plugin skill describe only explicit client/server entries. The standalone migration guide maps every former registration, and migration-doc.test.ts proves every client add* method remains represented. * fixup! feat(plugins): phase 1 split runtime entries after green gate Gate: compiler 13, runtime 24, typecheck, lint, and format passed. The dependency fixture proved node_modules/client was incorrectly treated as a plugin boundary. * fixup! feat(plugins): phase 2 run client entries after green gate Gate: client runtime and registry tests, typecheck, lint, format * fixup! docs(plugins): phase 4 publish migration after green gate Gate: migration docs test, paragraph audit, typecheck, lint, format * fixup! feat(plugins): phase 1 split runtime entries after green gate Gate: compiler organization tests, typecheck, lint, targeted format * fixup! feat(plugins): phase 2 run client entries after green gate Gate: shared SDK tests, runtime tests, typecheck, lint, targeted format * fixup! feat(cli): phase 3 scaffold runtime split after green gate * fixup! docs(plugins): phase 4 publish migration after green gate * fixup! feat(plugins): phase 1 split runtime entries after green gate * docs(plugins): rewrite public plugin docs for runtime entries and mobile guardrails * fixup! feat(cli): phase 3 scaffold runtime split after green gate The scaffold now keeps DOM globals out of the program and declares only window.open inside client/web.ts. The scaffold test invokes tsc in a fresh process, proves the generated project passes, and proves a stray document access fails. * fixup! docs(plugins): phase 4 publish migration after green gate The internal guide and plugin skill now forbid both the DOM lib and triple-slash DOM references. They direct web adapters to declare only the globals used by client/web.ts. * fixup! feat(plugins): phase 1 split runtime entries after green gate Removing the obsolete Babel parser changes the locked npm dependency graph. The macOS Nix desktop gate reported the new fixed-output hash, which this commit records. * fixup! feat(plugins): phase 1 split runtime entries after green gate Reject relative imports that escape the plugin root while continuing to skip resolved node_modules internals. The compiler regression test proves the escaped import is rejected and the dependency-internal fixture remains accepted. * fixup! feat(plugins): phase 1 split runtime entries after green gate Classify absolute imports and reject plugin-authored paths that escape into node_modules. Dependency internals remain exempt based on their importer path. Red-first compiler regressions cover both bypasses, and the existing dependency fixture remains green. * fixup! feat(plugins): phase 1 split runtime entries after green gate Classify canonical esbuild resolution results so dependency-relative and symlink imports cannot escape client/server boundaries. The two compiler regressions failed before the fix and pass afterward. * test(ci): synchronize flaky state transitions Wait for repository watcher registration before emitting buffered ref events, let sidebar order polling retry unlaid-out rows, and wait for the inactive browser parking state before screenshot capture. These changes directly address the three observed CI failures; the affected server, Playwright, and desktop browser tests pass locally. * test(ci): synchronize Mermaid completion layout assertion The Playwright streaming acceptance test exposed a completion remount between visibility and layout sampling. Check the existing completion promise around the measurement so that transition is not reported as diagram loss. * fixup! feat(plugins): phase 1 split runtime entries after green gate Resolve bare package imports before boundary classification so symlinked dependency entries cannot expose server modules to the client bundle. The focused regression proves the bypass and the compiler file passes 20/20. * fixup! feat(plugins): phase 1 split runtime entries after green gate Allow canonical paths only within a matching linked package root while preserving runtime and containment checks for imports that leave it. The linked-dependency regression fails before the fix and compiler tests pass 21/21 afterward. * fixup! feat(plugins): phase 1 split runtime entries after green gate Reject matching package manifests that contain the plugin or live inside it, so linked-dependency roots cannot exempt plugin or workspace files. The ancestor-manifest regression fails before the fix and compiler tests pass 22/22 afterward. * fixup! feat(plugins): phase 1 split runtime entries after green gate Validate plugin-local lexical boundaries before canonical linked-root exemptions and require remembered-root imports to originate inside that root. The linked-root bypass regression fails before the fix and compiler tests pass 23/23 afterward. --- docs/plugins.md | 141 +++-- nix/npm-deps.hash | 2 +- package-lock.json | 47 -- .../app/e2e/browser/plugin-host-ui.spec.ts | 2 +- .../app/e2e/browser/plugin-management.spec.ts | 12 +- packages/app/e2e/browser/plugin-theme.spec.ts | 2 +- .../browser/plugin-workspace-panels.spec.ts | 79 ++- packages/app/e2e/support/helpers/diagram.ts | 8 +- ...fecycle.test.ts => client-runtime.test.ts} | 42 +- .../lifecycle.ts => client-runtime.ts} | 45 +- .../command-center/contributions.test.ts | 2 +- packages/app/src/plugins/evaluate.test.ts | 107 +++- packages/app/src/plugins/evaluate.ts | 190 ++++-- .../app/src/plugins/registry-hooks.test.tsx | 6 +- packages/app/src/plugins/registry.test.ts | 48 +- packages/app/src/plugins/registry.ts | 47 +- .../app/src/plugins/sidebar-groups.test.ts | 2 +- .../src/plugins/surface-contribution.test.ts | 2 +- packages/app/src/plugins/theme.test.ts | 2 +- .../app/src/plugins/timeline/model.test.ts | 2 +- .../app/src/plugins/timeline/view.test.tsx | 22 +- packages/app/src/plugins/types.ts | 4 +- .../workspace-panels/locations.test.ts | 20 +- .../workspace-panels/resolution.test.ts | 2 +- .../cli/src/commands/plugin/scaffold.test.ts | 129 ++-- packages/cli/src/commands/plugin/scaffold.ts | 104 +++- .../cli/tests/e2e/plugin-lifecycle.test.ts | 6 +- packages/desktop/e2e/browser-tabs.e2e.mjs | 14 +- packages/plugin/src/contracts.ts | 31 +- packages/plugin/src/host.test.ts | 9 +- packages/plugin/src/host.ts | 91 +-- packages/plugin/src/index.ts | 18 +- packages/plugin/src/migration-doc.test.ts | 23 + packages/plugin/src/rpc.ts | 3 + packages/plugin/src/server.ts | 17 +- packages/plugin/tsconfig.examples.json | 2 +- packages/server/package.json | 1 - .../server/src/server/bootstrap.smoke.test.ts | 2 +- .../src/server/plugins/compiler.test.ts | 568 +++++++++++++----- .../server/src/server/plugins/compiler.ts | 356 +++++------ .../src/server/plugins/index.posix.test.ts | 24 +- .../plugins/managed-source.posix.test.ts | 8 +- .../plugins/plugin-paseo-api.e2e.test.ts | 18 +- .../src/server/plugins/plugin-process.ts | 15 +- .../server/plugins/plugin-sdk-specifiers.ts | 12 +- .../src/server/plugins/runtime.posix.test.ts | 186 ++++-- packages/server/src/server/plugins/runtime.ts | 91 ++- .../workspace-git-service.observation.test.ts | 13 +- plugin-examples/catppuccin/README.md | 3 +- .../catppuccin/{index.ts => index.client.ts} | 6 +- plugin-examples/inline-thinking/README.md | 1 + .../thinking.tsx} | 0 .../{index.ts => index.client.tsx} | 10 +- plugin-examples/linear/README.md | 6 +- plugin-examples/linear/index.client.ts | 7 + plugin-examples/linear/index.server.ts | 8 + plugin-examples/linear/index.ts | 9 - plugin-examples/linear/issues.server.ts | 11 - plugin-examples/linear/linear.test.ts | 2 +- plugin-examples/linear/server/issues.ts | 10 + plugin-examples/linear/{ => server}/linear.ts | 0 .../{issues.shared.ts => shared/issues.ts} | 2 +- .../{main.client.tsx => client/main.tsx} | 2 +- .../local-plugin/increment.server.ts | 9 - plugin-examples/local-plugin/index.client.tsx | 23 + plugin-examples/local-plugin/index.server.ts | 8 + plugin-examples/local-plugin/index.ts | 27 - .../local-plugin/server/increment.ts | 9 + .../increment.ts} | 2 +- plugin-examples/timeline-items/README.md | 3 + .../pi-tasks.tsx} | 2 +- .../timeline-items/index.client.tsx | 18 + plugin-examples/timeline-items/index.ts | 18 - .../timeline-items/pi-tasks.test.ts | 2 +- .../timeline-items/{ => shared}/pi-tasks.ts | 0 public-docs/plugins/index.md | 260 +++++--- public-docs/plugins/migration.md | 233 +++++++ public-docs/plugins/reference.md | 320 ++++++---- skills/paseo-plugin/SKILL.md | 209 +++++-- 79 files changed, 2436 insertions(+), 1361 deletions(-) rename packages/app/src/plugins/{composer-pills/lifecycle.test.ts => client-runtime.test.ts} (64%) rename packages/app/src/plugins/{composer-pills/lifecycle.ts => client-runtime.ts} (63%) create mode 100644 packages/plugin/src/migration-doc.test.ts rename plugin-examples/catppuccin/{index.ts => index.client.ts} (69%) rename plugin-examples/inline-thinking/{thinking.client.tsx => client/thinking.tsx} (100%) rename plugin-examples/inline-thinking/{index.ts => index.client.tsx} (61%) create mode 100644 plugin-examples/linear/index.client.ts create mode 100644 plugin-examples/linear/index.server.ts delete mode 100644 plugin-examples/linear/index.ts delete mode 100644 plugin-examples/linear/issues.server.ts create mode 100644 plugin-examples/linear/server/issues.ts rename plugin-examples/linear/{ => server}/linear.ts (100%) rename plugin-examples/linear/{issues.shared.ts => shared/issues.ts} (98%) rename plugin-examples/local-plugin/{main.client.tsx => client/main.tsx} (98%) delete mode 100644 plugin-examples/local-plugin/increment.server.ts create mode 100644 plugin-examples/local-plugin/index.client.tsx create mode 100644 plugin-examples/local-plugin/index.server.ts delete mode 100644 plugin-examples/local-plugin/index.ts create mode 100644 plugin-examples/local-plugin/server/increment.ts rename plugin-examples/local-plugin/{increment.shared.ts => shared/increment.ts} (78%) rename plugin-examples/timeline-items/{pi-tasks.client.tsx => client/pi-tasks.tsx} (97%) create mode 100644 plugin-examples/timeline-items/index.client.tsx delete mode 100644 plugin-examples/timeline-items/index.ts rename plugin-examples/timeline-items/{ => shared}/pi-tasks.ts (100%) create mode 100644 public-docs/plugins/migration.md diff --git a/docs/plugins.md b/docs/plugins.md index 034b585d5f..5f5a65dcba 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,9 +1,8 @@ # Plugins Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items, -client slash commands, timeline items, composer pills, app themes, and composer attachment sources -from one `index.ts`. Paseo executes the server contribution in a subprocess and evaluates the client -contribution in the app runtime. +client slash commands, timeline items, composer pills, app themes, and composer attachment sources. +Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app. > **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host. @@ -42,15 +41,19 @@ runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts ev enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries remain lifecycle-owned and do not reload from manual config edits. -The directory contains an identity-only manifest, one entry point, and local typechecking support: +The directory contains an identity-only manifest, one optional entry per runtime, runtime-owned +directories, and local typechecking support. At least one entry is required. ```text my-plugin/ paseo-plugin.json - index.ts - main.client.tsx package.json tsconfig.json + index.client.tsx + index.server.ts + client/greeting.tsx + server/greeting.ts + shared/greeting.ts ``` The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development @@ -136,16 +139,19 @@ log credentials or tokens. ## Contribute behavior and UI -Default export one contribution function from `index.ts`. Keep it to contribution wiring. Runtime -code lives behind filename boundaries: +Default export one contribution function from each runtime entry. Keep the entries to registration +wiring. Runtime code lives behind directory boundaries: -| Suffix | Owns | -| -------------- | -------------------------------------------------------------------- | -| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, and callbacks. | -| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. | -| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. | +| Path | Owns | +| -------------------------------- | -------------------------------------------------------------------- | +| `index.client.tsx` and `client/` | React, React Native, hooks, styles, surfaces, panels, and callbacks. | +| `index.server.ts` and `server/` | Node APIs, filesystem and process access, credentials, and handlers. | +| `shared/` | Zod RPC contracts and plain values used by both runtimes. | -Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from +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 set; an unknown name renders nothing so it cannot break the plugin surface. Its controlled modal keeps presentation metadata on `` and body UI in @@ -154,28 +160,45 @@ 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/reference.md`. -| Module | Use it for | -| ------------------------------- | -------------------------------------------------------- | -| `@getpaseo/plugin` | contribution contracts and client data hooks | -| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks | -| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types | +| 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` | -The compiler removes client registrations and imports from the server entry point, and server -registrations and imports from the client entry point. Importing a `*.server` module from a client -module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls -such as `StyleSheet.create` belong in `*.client.tsx`; placing them in `index.ts` executes them in the -server bundle. +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 +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/`. + +The scaffold omits `"DOM"` from `tsconfig.json` and does not use `/// `, so +browser globals are not available across the plugin. Put sanctioned web-only APIs in +`client/web.ts`, declare only the globals that module uses, gate each export with +`Platform.OS === "web"`, and provide a native implementation or no-op. See the +[public plugin reference](../public-docs/plugins/reference.md#works-on-mobile) for the complete +pattern. ```ts -import type { PluginContext } from "@getpaseo/plugin"; -import { Greeting } from "./greeting.client"; -import { createGreeting } from "./greeting.server"; -import { greetRpc } from "./greeting.shared"; +// index.server.ts +import type { PluginServerContext } from "@getpaseo/plugin"; +import { createGreeting } from "./server/greeting"; +import { greetRpc } from "./shared/greeting"; -export default function contribute(plugin: PluginContext) { - plugin.handle(greetRpc, createGreeting); - plugin.addSurface("main", Greeting); - plugin.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" }); +export default function contribute(server: PluginServerContext) { + server.handle(greetRpc, createGreeting); + return () => {}; +} +``` + +```tsx +// index.client.tsx +import type { PluginClientContext } from "@getpaseo/plugin"; +import { Greeting } from "./client/greeting"; + +export default function contribute(client: PluginClientContext) { + client.addSurface("main", Greeting); + client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" }); return () => {}; } ``` @@ -232,10 +255,10 @@ Plugins do not receive Expo Router or workspace-layout store access. ## Contribute composer pills -Register a headless client entrypoint, then add and remove targeted pills from that client -lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs -in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary -client state without mounting a panel or surface. +Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per +plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe +to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or +surface. ```tsx export function contributeClient(client: PluginClientContext) { @@ -266,8 +289,8 @@ export function contributeClient(client: PluginClientContext) { } ``` -Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only -on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the +Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry. +`addComposerPill` returns an idempotent removal function. A pill appears only in the matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable, shared chrome, pending state, error reporting, and placement. The component owns its icon and text; the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting @@ -306,21 +329,22 @@ await paseo.agents.ref(agentId).timeline.append({ ``` The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id` -replaces the previous row from that plugin on live clients and fresh timeline fetches. The row stays -in canonical history and renders an unavailable placeholder when its renderer is not installed. -Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a -payload that cannot be rendered intact. The daemon advertises this RPC through +replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in +the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon +restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data` +must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot +be rendered intact. The daemon advertises this RPC through `server_info.features.pluginTimelineItems`. ## Contribute client slash commands -`addClientSlashCommand` registers an agent- or workspace-context command in the composer. The +`addSlashCommand` registers an agent- or workspace-context command in the composer. The callback runs in the app, receives the trimmed text after the command name as `args`, and receives the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching Command Center callback. ```ts -plugin.addClientSlashCommand({ +client.addSlashCommand({ name: "review", description: "Run the review bot", argumentHint: "[scope]", @@ -343,13 +367,24 @@ search picker, drafts, selected pill, and submission. The plugin returns complet credentials and vendor API calls stay in the daemon handler. ```ts -import type { PluginContext } from "@getpaseo/plugin"; -import { search } from "./issues.server"; -import { issues, searchIssues } from "./issues.shared"; +// index.server.ts +import type { PluginServerContext } from "@getpaseo/plugin"; +import { search } from "./server/issues"; +import { searchIssues } from "./shared/issues"; -export default function contribute(plugin: PluginContext) { - plugin.handle(searchIssues, search); - plugin.addAttachmentSource(issues); +export default function contribute(server: PluginServerContext) { + server.handle(searchIssues, search); + return () => {}; +} +``` + +```tsx +// index.client.tsx +import type { PluginClientContext } from "@getpaseo/plugin"; +import { issues } from "./shared/issues"; + +export default function contribute(client: PluginClientContext) { + client.addAttachmentSource(issues); return () => {}; } ``` @@ -368,8 +403,8 @@ contract. Unistyles needs every theme name at `StyleSheet.configure` time, so provider rewrites the matching slot when the selection changes. See [unistyles.md](unistyles.md) for the runtime-patching rules the appearance settings share. -`addTheme` is a client registration, so the compiler strips it from the backend bundle. A daemon -that predates it does not, and the plugin fails to start there. Daemons advertise +`addTheme` is a client registration and belongs in `index.client.tsx`. A client that predates it +cannot evaluate that entry. Daemons advertise `features.pluginThemes` in `server_info`; the plugin theme catalog is the one place the app reads it, and a host without it contributes no themes. @@ -382,6 +417,8 @@ stable rather than arrival-ordered. The app resolves that id against the installed catalog on every change; an id nothing contributes falls back to the default preference instead of painting the reserved slot's placeholder colors. +Existing plugin authors should follow the standalone [runtime-entry migration guide](../public-docs/plugins/migration.md). + See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete attachment-source example, `plugin-examples/timeline-items` for timeline projection, and `plugin-examples/catppuccin` for a theme. diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 60e4a10c70..4d257077c9 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-0hOGev0HglOQmofzPQMfiWh1opg6cpiEgsfK22AKcGk= +sha256-R5NuJTwMOYPo0i60qhF0lZU+OMHwjnIKf9gI1fCJ9/M= diff --git a/package-lock.json b/package-lock.json index a0eeb704ff..6e9576aba6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39056,7 +39056,6 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@babel/parser": "^7.29.8", "@getpaseo/client": "0.7.2", "@getpaseo/highlight": "0.7.2", "@getpaseo/plugin": "0.7.2", @@ -39106,52 +39105,6 @@ "vitest": "^4.1.6" } }, - "packages/server/node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "packages/server/node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "packages/server/node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "packages/server/node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "packages/server/node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index 7396610c92..7fcc72bdb7 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -119,7 +119,7 @@ test("plugin modal adapts its presentation and preserves host contexts", async ( const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previousConfig = await client.getDaemonConfig(); await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID })); - await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE); + await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE); try { await client.patchDaemonConfig({ pluginsEnabled: true }); diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index da4b042d42..823b792a5d 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -121,7 +121,7 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin" const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" })); - await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1")); + await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1")); try { const catalog = observePluginCatalog(page); @@ -143,14 +143,14 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin" await reloadPlugin(page); await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1"); - await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2")); + await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2")); await openPluginSettings(page); await reloadPlugin(page); await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2"); await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible(); await writeFile( - path.join(directory, "index.tsx"), + path.join(directory, "index.client.tsx"), pluginSource("Broken surface", "render exploded"), ); await openPluginSettings(page); @@ -159,13 +159,13 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin" await openContribution(page, "Healthy surface", "Healthy contribution"); await openContribution(page, "Broken surface", "Plugin failed: render exploded"); - await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface")); + await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface")); await reloadActiveContribution(page, client, "Recovered surface cleanup 4"); await expect( page.getByRole("button", { name: "Broken surface", exact: true }), ).not.toBeVisible(); - await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!"); + await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!"); await openPluginSettings(page); await page.getByRole("button", { name: "Reload", exact: true }).click(); await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed"); @@ -173,7 +173,7 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin" await leavePluginSettings(page); await expectContributionRemoved(page, "Recovered surface"); - await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3")); + await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3")); await openPluginSettings(page); await page.getByRole("button", { name: "Reload", exact: true }).click(); await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); diff --git a/packages/app/e2e/browser/plugin-theme.spec.ts b/packages/app/e2e/browser/plugin-theme.spec.ts index 803dca98c9..aa579ae62c 100644 --- a/packages/app/e2e/browser/plugin-theme.spec.ts +++ b/packages/app/e2e/browser/plugin-theme.spec.ts @@ -54,7 +54,7 @@ test("applies a contributed theme and falls back when its plugin is gone", async const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previousConfig = await client.getDaemonConfig(); await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID })); - await writeFile(path.join(directory, "index.ts"), PLUGIN_SOURCE); + await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE); try { await client.patchDaemonConfig({ pluginsEnabled: true }); diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts index 86c3fe6ecf..80f19ce1e4 100644 --- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { TestInfo } from "@playwright/test"; @@ -26,18 +26,11 @@ function isSettledWorkspaceUrl(url: URL): boolean { return url.pathname.includes("/workspace/") && !url.searchParams.has("open"); } -function pluginSource(input: { workspaceId: string; agentId: string }): string { +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 { defineRpc } from "@getpaseo/plugin/server"; -import { z } from "zod"; - -const recordComposerOpen = defineRpc({ - name: "composer.open", - input: z.object({ workspaceId: z.string() }), - output: z.object({ opened: z.boolean() }), -}); +import { recordComposerOpen } from "./shared/rpc"; function WorkspacePanel({ workspaceId, host, layout }) { const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id })); @@ -111,23 +104,49 @@ function contributeClient(client) { }; } -export default function contribute(plugin) { - plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => { +export default function contribute(client) { + client.addSurface("collision", DirectCollisionSurface); + client.addSurface("sidebar-destination", SidebarCollisionSurface); + client.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" }); + client.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel }); + client.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel }); + client.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} }); + client.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } }); + client.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } }); + client.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } }); + return contributeClient(client); +}`; +} + +const pluginSharedSource = `import { defineRpc } from "@getpaseo/plugin"; +import { z } from "zod"; + +export const recordComposerOpen = defineRpc({ + name: "composer.open", + input: z.object({ workspaceId: z.string() }), + output: z.object({ opened: z.boolean() }), +});`; + +const pluginServerSource = `import { recordComposerOpen } from "./shared/rpc"; + +export default function contribute(server) { + server.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => { await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill"); return { opened: true }; }); - plugin.addSurface("collision", DirectCollisionSurface); - plugin.addSurface("sidebar-destination", SidebarCollisionSurface); - plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" }); - plugin.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel }); - plugin.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel }); - plugin.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} }); - plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } }); - plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } }); - plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } }); - plugin.addClientSide(contributeClient); return () => {}; }`; + +async function writePluginSources( + directory: string, + input: { workspaceId: string; agentId: string }, +): Promise { + await mkdir(path.join(directory, "shared"), { recursive: true }); + await Promise.all([ + writeFile(path.join(directory, "index.client.tsx"), pluginClientSource(input)), + writeFile(path.join(directory, "index.server.ts"), pluginServerSource), + writeFile(path.join(directory, "shared", "rpc.ts"), pluginSharedSource), + ]); } async function searchCommands(page: Page, query: string) { @@ -181,10 +200,10 @@ test.describe("plugin workspace panels and Command Center", () => { port: secondaryDaemon.port, }); await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID })); - await writeFile( - path.join(directory, "index.tsx"), - pluginSource({ workspaceId: primary.workspaceId, agentId: "missing-agent" }), - ); + await writePluginSources(directory, { + workspaceId: primary.workspaceId, + agentId: "missing-agent", + }); try { await primaryClient.patchDaemonConfig({ pluginsEnabled: true }); @@ -260,10 +279,10 @@ test.describe("plugin workspace panels and Command Center", () => { modeId: "load-test", }); const navigationAgentId = agent.id; - await writeFile( - path.join(directory, "index.tsx"), - pluginSource({ workspaceId: primary.workspaceId, agentId: navigationAgentId }), - ); + await writePluginSources(directory, { + workspaceId: primary.workspaceId, + agentId: navigationAgentId, + }); await primaryClient.reloadPlugin(PLUGIN_ID); await runCommand(page, "Open direct collision surface"); diff --git a/packages/app/e2e/support/helpers/diagram.ts b/packages/app/e2e/support/helpers/diagram.ts index bfa91b36e6..82fd745de1 100644 --- a/packages/app/e2e/support/helpers/diagram.ts +++ b/packages/app/e2e/support/helpers/diagram.ts @@ -30,10 +30,16 @@ export async function expectDiagramRemainsRenderedWhileStreaming( completion: Promise, ): Promise { const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last(); - const completed = completion.then(() => true); + let turnCompleted = false; + const completed = completion.then(() => { + turnCompleted = true; + return true; + }); for (;;) { expect(await diagram.isVisible()).toBe(true); + if (turnCompleted) return completion; const box = await diagram.boundingBox(); + if (turnCompleted) return completion; expect(box, "the inline diagram should have measurable layout").not.toBeNull(); expect( box?.height, diff --git a/packages/app/src/plugins/composer-pills/lifecycle.test.ts b/packages/app/src/plugins/client-runtime.test.ts similarity index 64% rename from packages/app/src/plugins/composer-pills/lifecycle.test.ts rename to packages/app/src/plugins/client-runtime.test.ts index 2d119b609a..2be61bbc2e 100644 --- a/packages/app/src/plugins/composer-pills/lifecycle.test.ts +++ b/packages/app/src/plugins/client-runtime.test.ts @@ -1,16 +1,16 @@ import { QueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PluginClientContext, PluginComposerPillProps } from "@getpaseo/plugin"; +import type { PluginComposerPillProps } from "@getpaseo/plugin"; import { afterEach, describe, expect, it } from "vitest"; -import type { InstalledPlugin } from "../types"; -import { startPluginClientSide } from "./lifecycle"; -import { pluginComposerPillStore } from "./store"; +import type { InstalledPlugin } from "./types"; +import { createPluginClientRuntime } from "./client-runtime"; +import { pluginComposerPillStore } from "./composer-pills/store"; function Pill(_props: PluginComposerPillProps) { return null; } -function installation(clientSide: NonNullable): InstalledPlugin { +function installation(): InstalledPlugin { return { id: "review", serverId: "host-a", @@ -21,7 +21,7 @@ function installation(clientSide: NonNullable): I sidebarItems: [], workspacePanels: [], commandCenterItems: [], - clientSide, + clientSlashCommands: [], attachmentSources: [], themes: [], timelineTransformers: [], @@ -39,19 +39,10 @@ afterEach(() => { }); describe("plugin client-side lifecycle", () => { - it("lets client code add and remove a targeted composer pill at runtime", async () => { - const captured: { client?: PluginClientContext } = {}; - let cleanupCount = 0; - const plugin = installation((context) => { - captured.client = context; - return () => { - cleanupCount += 1; - }; - }); + it("lets the client runtime add and remove a targeted composer pill", () => { + const plugin = installation(); installations.push(plugin); - const stop = startPluginClientSide(plugin, daemonClient); - const client = captured.client; - if (!client) throw new Error("Client lifecycle did not start"); + const client = createPluginClientRuntime(plugin, daemonClient); expect(pluginComposerPillStore.getSnapshot()).toEqual([]); const remove = client.addComposerPill({ @@ -73,23 +64,10 @@ describe("plugin client-side lifecycle", () => { remove(); expect(pluginComposerPillStore.getSnapshot()).toEqual([]); - - client.addComposerPill({ - id: "review-ready", - title: "Open review", - workspaceId: "workspace-a", - agentId: "agent-a", - Component: Pill, - onPress() {}, - }); - await stop(); - - expect(pluginComposerPillStore.getSnapshot()).toEqual([]); - expect(cleanupCount).toBe(1); }); it("rejects duplicate pills only within the same plugin target", () => { - const plugin = installation(() => () => undefined); + const plugin = installation(); installations.push(plugin); const contribution = { id: "review-ready", diff --git a/packages/app/src/plugins/composer-pills/lifecycle.ts b/packages/app/src/plugins/client-runtime.ts similarity index 63% rename from packages/app/src/plugins/composer-pills/lifecycle.ts rename to packages/app/src/plugins/client-runtime.ts index 669e8c513d..cef8bbb341 100644 --- a/packages/app/src/plugins/composer-pills/lifecycle.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -1,25 +1,21 @@ -import type { - PluginCleanup, - PluginClientContext, - PluginClientOpenPanelOptions, -} from "@getpaseo/plugin"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin"; import { createPluginAgentActionContext, createPluginCapabilities, createPluginWorkspaceActionContext, -} from "../actions"; -import { createPluginClientStateSource } from "../client-state/source"; -import { createPluginNavigation } from "../navigation"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; -import type { InstalledPlugin } from "../types"; -import { pluginComposerPillStore } from "./store"; +} from "./actions"; +import { createPluginClientStateSource } from "./client-state/source"; +import type { PluginClientRuntime } from "./evaluate"; +import { createPluginNavigation } from "./navigation"; +import { pluginComposerPillStore } from "./composer-pills/store"; +import { createPluginSurfaceRuntime } from "./surface-runtime"; +import type { InstalledPlugin } from "./types"; -export function startPluginClientSide( +export function createPluginClientRuntime( installation: InstalledPlugin, daemonClient: DaemonClient, -): PluginCleanup { - if (!installation.clientSide) return () => undefined; +): PluginClientRuntime { const runtime = createPluginSurfaceRuntime(daemonClient, installation.id); if (!runtime) throw new Error("Plugin host is offline"); const state = createPluginClientStateSource(installation.serverId); @@ -28,7 +24,7 @@ export function startPluginClientSide( runtime, createPluginNavigation({ serverId: installation.serverId, workspaceId: null }), ); - const context: PluginClientContext = { + return { ...capabilities, addComposerPill(contribution) { return pluginComposerPillStore.add(installation, contribution); @@ -37,25 +33,6 @@ export function startPluginClientSide( openClientPanel({ installation, runtime, state, panelId, options }); }, }; - - let cleanup: PluginCleanup; - try { - cleanup = installation.clientSide(context); - if (typeof cleanup !== "function") { - throw new Error("Plugin client-side entrypoint must return a cleanup function"); - } - } catch (error) { - pluginComposerPillStore.removeInstallation(installation); - throw error; - } - - let stopped = false; - return async () => { - if (stopped) return; - stopped = true; - pluginComposerPillStore.removeInstallation(installation); - await cleanup(); - }; } function openClientPanel(input: { diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index c0933c0975..51a6df5029 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -97,7 +97,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { onSelect: onAgentSelect, }, ], - clientSide: null, + clientSlashCommands: [], attachmentSources: [], themes: [], timelineTransformers: [], diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index cff06923d2..698053102a 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -1,5 +1,19 @@ import { describe, expect, it } from "vitest"; -import { evaluatePluginClientBundle } from "./evaluate"; +import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate"; + +const runtime = { + paseo: {}, + async rpc() {}, + openSurface() {}, + openPanel() {}, + addComposerPill() { + return () => undefined; + }, +} as unknown as PluginClientRuntime; + +function evaluatePluginClientBundle(id: string, source: string) { + return runPluginClientBundle(id, source, runtime); +} function bundle(body: string): string { return `(function() { @@ -10,6 +24,73 @@ function bundle(body: string): string { } describe("evaluatePluginClientBundle", () => { + it("returns idempotent removers for every client registration", () => { + let pillCount = 0; + const plugin = runPluginClientBundle( + "removals", + bundle(` + function Component() { return null; } + const schema = { safeParse(value) { return { success: true, data: value }; } }; + globalThis.__pluginRemovals = [ + plugin.addSurface("main", Component), + plugin.addSidebarItem({ id: "main", title: "Main", icon: "Blocks", surface: "main" }), + plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }), + plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }), + plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }), + plugin.addComposerPill({ id: "pill", title: "Pill", workspaceId: "workspace", agentId: "agent", Component, onPress() {} }), + plugin.addAttachmentSource({ id: "issues", title: "Issues", icon: "Blocks", pickerTitle: "Attach issue", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } }), + plugin.addTheme({ id: "night", name: "Night", appearance: "dark", colors: { background: "#000", foreground: "#fff", raised: "#111", control: "#222", border: "#333", mutedForeground: "#aaa", ring: "#555" } }), + plugin.addTimelineTransformer({ id: "transformer", query: { itemType: "tool_call" }, transform() { return { items: [] }; } }), + plugin.addTimelineRenderer({ kind: "card", version: 1, schema, Component }), + ]; + `), + { + ...runtime, + addComposerPill() { + pillCount += 1; + return () => { + pillCount -= 1; + }; + }, + }, + ); + const removals = Reflect.get(globalThis, "__pluginRemovals") as Array<() => void>; + + expect( + [ + plugin.surfaces, + plugin.sidebarItems, + plugin.workspacePanels, + plugin.commandCenterItems, + plugin.clientSlashCommands, + plugin.attachmentSources, + plugin.themes, + plugin.timelineTransformers, + plugin.timelineRenderers, + ].every((items) => items.length === 1), + ).toBe(true); + expect(pillCount).toBe(1); + for (const remove of removals) { + remove(); + remove(); + } + expect( + [ + plugin.surfaces, + plugin.sidebarItems, + plugin.workspacePanels, + plugin.commandCenterItems, + plugin.clientSlashCommands, + plugin.attachmentSources, + plugin.themes, + plugin.timelineTransformers, + plugin.timelineRenderers, + ].every((items) => items.length === 0), + ).toBe(true); + expect(pillCount).toBe(0); + Reflect.deleteProperty(globalThis, "__pluginRemovals"); + }); + it("collects timeline transformers and renderers", () => { const plugin = evaluatePluginClientBundle( "reports", @@ -212,26 +293,16 @@ describe("evaluatePluginClientBundle", () => { ).toThrow("Duplicate Command Center item: review"); }); - it("collects one explicit client-side entrypoint", () => { + it("runs the client entry with the full runtime context", () => { const plugin = evaluatePluginClientBundle( "review", bundle(` - function contributeClient() { return function() {}; } - plugin.addClientSide(contributeClient); + if (!plugin.paseo || !plugin.rpc || !plugin.openSurface || !plugin.openPanel || !plugin.addComposerPill) { + throw new Error("missing client runtime"); + } `), ); - expect(plugin.clientSide).toBeTypeOf("function"); - - expect(() => - evaluatePluginClientBundle( - "review", - bundle(` - function contributeClient() { return function() {}; } - plugin.addClientSide(contributeClient); - plugin.addClientSide(contributeClient); - `), - ), - ).toThrow("Plugin has more than one client-side entrypoint"); + expect(plugin.id).toBe("review"); }); it("rejects duplicate attachment source ids", () => { @@ -397,11 +468,11 @@ describe("evaluatePluginClientBundle", () => { expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]); }); - it("resolves @getpaseo/plugin/server for shared RPC contracts", () => { + it("resolves shared RPC helpers from @getpaseo/plugin", () => { const plugin = evaluatePluginClientBundle( "example", `(function(require) { - const { defineRpc, defineAttachmentSource } = require("@getpaseo/plugin/server"); + const { defineRpc, defineAttachmentSource } = require("@getpaseo/plugin"); const search = defineRpc({ name: "issues.search", input: {}, output: {} }); const module = { exports: {} }; module.exports.default = function(plugin) { diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index e0f6b60c6b..14ace0607a 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -10,7 +10,8 @@ import { defineRpc, type PluginAttachmentSourceContribution, type PluginCommandCenterItemContribution, - type PluginClientContribution, + type PluginCleanup, + type PluginClientContext, type PluginClientSlashCommandContribution, type PluginSidebarContribution, type PluginSurfaceProps, @@ -23,7 +24,6 @@ import { useWorkspace, useRpc, } from "@getpaseo/plugin"; -import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo/plugin/host"; import type { EvaluatedPlugin } from "./types"; import type { ComponentType } from "react"; import { Icon, resolvePluginIcon } from "./icons"; @@ -68,14 +68,23 @@ function requireId(value: string, label: string): string { return id; } -export function evaluatePluginClientBundle(id: string, bundle: string): EvaluatedPlugin { - const collector: PluginRegistrationCollector = { +export type PluginClientRuntime = Pick< + PluginClientContext, + "paseo" | "rpc" | "openSurface" | "openPanel" | "addComposerPill" +>; + +export function runPluginClientBundle( + id: string, + bundle: string, + runtime: PluginClientRuntime, + onChange: () => void = () => undefined, +): EvaluatedPlugin { + const collector: Omit = { surfaces: [], sidebarItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], - clientSide: null, attachmentSources: [], themes: [], timelineTransformers: [], @@ -90,14 +99,38 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate const themeIds = new Set(); const timelineTransformerIds = new Set(); const timelineRendererIds = new Set(); - const pluginContext = createPluginContext({ + const removals = new Set(); + let setupComplete = false; + const notifyChange = () => { + if (setupComplete) onChange(); + }; + function register(items: T[], item: T, release: () => void): PluginCleanup { + items.push(item); + notifyChange(); + let active = true; + const remove = () => { + if (!active) return; + active = false; + const index = items.indexOf(item); + if (index !== -1) items.splice(index, 1); + release(); + removals.delete(remove); + notifyChange(); + }; + removals.add(remove); + return remove; + } + const pluginContext: PluginClientContext = { + ...runtime, addSurface(surfaceId: string, Component: ComponentType) { const normalizedId = requireId(surfaceId, "surface id"); if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`); if (typeof Component !== "function") throw new Error(`Surface ${normalizedId} is not a component`); surfaceIds.add(normalizedId); - collector.surfaces.push({ id: normalizedId, Component }); + return register(collector.surfaces, { id: normalizedId, Component }, () => + surfaceIds.delete(normalizedId), + ); }, addSidebarItem(contribution: PluginSidebarContribution) { const normalizedId = requireId(contribution.id, "sidebar item id"); @@ -107,12 +140,16 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`); resolvePluginIcon(contribution.icon.trim()); sidebarItemIds.add(normalizedId); - collector.sidebarItems.push({ - id: normalizedId, - title: contribution.title.trim(), - icon: contribution.icon.trim(), - surface: requireId(contribution.surface, "sidebar surface id"), - }); + return register( + collector.sidebarItems, + { + id: normalizedId, + title: contribution.title.trim(), + icon: contribution.icon.trim(), + surface: requireId(contribution.surface, "sidebar surface id"), + }, + () => sidebarItemIds.delete(normalizedId), + ); }, addWorkspacePanel(contribution: PluginWorkspacePanelContribution) { const normalizedId = requireId(contribution.id, "workspace panel id"); @@ -132,13 +169,17 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate resolvePluginIcon(icon); const locations = normalizePanelLocations(normalizedId, contribution.locations); workspacePanelIds.add(normalizedId); - collector.workspacePanels.push({ - ...contribution, - id: normalizedId, - title, - icon, - locations, - }); + return register( + collector.workspacePanels, + { + ...contribution, + id: normalizedId, + title, + icon, + locations, + }, + () => workspacePanelIds.delete(normalizedId), + ); }, addCommandCenterItem(contribution: PluginCommandCenterItemContribution) { const normalizedId = requireId(contribution.id, "Command Center item id"); @@ -161,15 +202,19 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate } resolvePluginIcon(icon); commandCenterItemIds.add(normalizedId); - collector.commandCenterItems.push({ - ...contribution, - id: normalizedId, - title, - icon, - keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean), - }); + return register( + collector.commandCenterItems, + { + ...contribution, + id: normalizedId, + title, + icon, + keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean), + }, + () => commandCenterItemIds.delete(normalizedId), + ); }, - addClientSlashCommand(contribution: PluginClientSlashCommandContribution) { + addSlashCommand(contribution: PluginClientSlashCommandContribution) { const name = requireId(contribution.name, "client slash command name"); if (clientSlashCommandNames.has(name)) { throw new Error(`Duplicate client slash command: ${name}`); @@ -183,19 +228,16 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate throw new Error(`Client slash command ${name} has no callback`); } clientSlashCommandNames.add(name); - collector.clientSlashCommands.push({ - ...contribution, - name, - description, - argumentHint: contribution.argumentHint.trim(), - }); - }, - addClientSide(contribution: PluginClientContribution) { - if (collector.clientSide) throw new Error("Plugin has more than one client-side entrypoint"); - if (typeof contribution !== "function") { - throw new Error("Plugin client-side entrypoint is not a function"); - } - collector.clientSide = contribution; + return register( + collector.clientSlashCommands, + { + ...contribution, + name, + description, + argumentHint: contribution.argumentHint.trim(), + }, + () => clientSlashCommandNames.delete(name), + ); }, addAttachmentSource(contribution: PluginAttachmentSourceContribution) { const normalizedId = requireId(contribution.id, "attachment source id"); @@ -216,21 +258,25 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`); resolvePluginIcon(icon); attachmentSourceIds.add(normalizedId); - collector.attachmentSources.push({ - id: normalizedId, - title, - icon, - pickerTitle, - searchPlaceholder, - search: { ...contribution.search, name: method }, - }); + return register( + collector.attachmentSources, + { + id: normalizedId, + title, + icon, + pickerTitle, + searchPlaceholder, + search: { ...contribution.search, name: method }, + }, + () => attachmentSourceIds.delete(normalizedId), + ); }, addTheme(contribution: PluginThemeContribution) { const normalizedId = requireId(contribution.id, "theme id"); if (themeIds.has(normalizedId)) throw new Error(`Duplicate theme: ${normalizedId}`); const theme = parsePluginThemeContribution({ ...contribution, id: normalizedId }); themeIds.add(normalizedId); - collector.themes.push(theme); + return register(collector.themes, theme, () => themeIds.delete(normalizedId)); }, addTimelineTransformer(contribution: PluginTimelineTransformerContribution) { const normalizedId = requireId(contribution.id, "timeline transformer id"); @@ -252,7 +298,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate throw new Error(`Invalid timeline transformer id: ${contribution.id}`); } timelineTransformerIds.add(normalizedId); - collector.timelineTransformers.push(contribution); + return register(collector.timelineTransformers, contribution, () => + timelineTransformerIds.delete(normalizedId), + ); }, addTimelineRenderer(contribution: PluginTimelineRendererContribution) { const kind = requireId(contribution.kind, "timeline renderer kind"); @@ -270,9 +318,23 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate throw new Error(`Timeline renderer ${rendererId} is not a component`); } timelineRendererIds.add(rendererId); - collector.timelineRenderers.push({ ...contribution, kind }); + return register(collector.timelineRenderers, { ...contribution, kind }, () => + timelineRendererIds.delete(rendererId), + ); }, - }); + addComposerPill(contribution) { + const removePill = runtime.addComposerPill(contribution); + let active = true; + const remove = () => { + if (!active) return; + active = false; + removePill(); + removals.delete(remove); + }; + removals.add(remove); + return remove; + }, + }; const runtimeRequire = (name: string): unknown => { if (name === "react") return React; if (name === "react/jsx-runtime") return ReactJsxRuntime; @@ -291,8 +353,8 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") { return pluginReactNativeRuntime; } - if (name === "@getpaseo/plugin/server") { - return { defineAttachmentSource, defineRpc }; + if (name === "@getpaseo/plugin/server" || name === "@paseo/plugin/server") { + return {}; } if (name === "@tanstack/react-query") return ReactQuery; if (name === "zod") return Zod; @@ -308,8 +370,8 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate if (typeof setup !== "function") { throw new Error(`Plugin ${id} must default export a function`); } - const cleanup = setup(pluginContext); - if (typeof cleanup !== "function") { + const entryCleanup = setup(pluginContext); + if (typeof entryCleanup !== "function") { throw new Error(`Plugin ${id} contribution must return a cleanup function`); } @@ -321,7 +383,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate } } catch (error) { try { - void Promise.resolve(cleanup()).catch((cleanupError) => { + void Promise.resolve(entryCleanup()).catch((cleanupError) => { console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError); }); } catch (cleanupError) { @@ -329,6 +391,17 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate } throw error; } + setupComplete = true; + let stopped = false; + const cleanup = async () => { + if (stopped) return; + stopped = true; + try { + await entryCleanup(); + } finally { + for (const remove of removals) remove(); + } + }; return { id, cleanup, @@ -337,7 +410,6 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"], commandCenterItems: collector.commandCenterItems, clientSlashCommands: collector.clientSlashCommands, - clientSide: collector.clientSide, attachmentSources: collector.attachmentSources, themes: collector.themes, timelineTransformers: collector.timelineTransformers, diff --git a/packages/app/src/plugins/registry-hooks.test.tsx b/packages/app/src/plugins/registry-hooks.test.tsx index 532ef8b029..caf9bac326 100644 --- a/packages/app/src/plugins/registry-hooks.test.tsx +++ b/packages/app/src/plugins/registry-hooks.test.tsx @@ -5,10 +5,10 @@ import { renderHook } from "@testing-library/react"; import { expect, it, vi } from "vitest"; vi.mock("./evaluate", () => ({ - evaluatePluginClientBundle: vi.fn(), + runPluginClientBundle: vi.fn(), })); -vi.mock("./composer-pills/lifecycle", () => ({ - startPluginClientSide: vi.fn(), +vi.mock("./client-runtime", () => ({ + createPluginClientRuntime: vi.fn(), })); import { usePluginInstallations } from "./registry"; diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts index 48ef2e35d6..a0fc4c068f 100644 --- a/packages/app/src/plugins/registry.test.ts +++ b/packages/app/src/plugins/registry.test.ts @@ -1,5 +1,33 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { pluginRegistry } from "./registry"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { pluginRegistry as registry } from "./registry"; + +vi.mock("./navigation", () => ({ + createPluginNavigation: () => ({}), +})); +vi.mock("./client-runtime", () => ({ + createPluginClientRuntime: () => ({ + paseo: {}, + rpc: async () => undefined, + openSurface: () => undefined, + openPanel: () => undefined, + addComposerPill: () => () => undefined, + }), +})); + +const daemonClient = {} as DaemonClient; +const pluginRegistry = { + getSnapshot: registry.getSnapshot, + subscribe: registry.subscribe, + removeHost: registry.removeHost.bind(registry), + installCatalog( + serverId: string, + catalog: Parameters[1], + options: { replacePluginId?: string } = {}, + ) { + return registry.installCatalog(serverId, catalog, { ...options, client: daemonClient }); + }, +}; function bundle(marker: string): string { return `(function() { @@ -29,6 +57,10 @@ function timelineBundle(marker: string): string { })`; } +function installedPluginIds(): string[] { + return pluginRegistry.getSnapshot().map(({ id }) => id); +} + afterEach(() => { pluginRegistry.removeHost("host-a"); pluginRegistry.removeHost("host-b"); @@ -36,6 +68,18 @@ afterEach(() => { }); describe("PluginRegistry", () => { + it("publishes synchronous setup once after storing the installation", () => { + const snapshots: string[][] = []; + const unsubscribe = pluginRegistry.subscribe(() => { + snapshots.push(installedPluginIds()); + }); + + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]); + unsubscribe(); + + expect(snapshots).toEqual([["example"]]); + }); + it("reports timeline contribution changes", () => { const first = timelineBundle("one"); expect(pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle: first }])).toBe( diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index fd925489a8..d7f5845e5a 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -1,8 +1,8 @@ import { useMemo, useSyncExternalStore } from "react"; import { QueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { startPluginClientSide } from "./composer-pills/lifecycle"; -import { evaluatePluginClientBundle } from "./evaluate"; +import { createPluginClientRuntime } from "./client-runtime"; +import { runPluginClientBundle } from "./evaluate"; import type { InstalledPlugin } from "./types"; interface CatalogPlugin { @@ -33,8 +33,8 @@ class PluginRegistry { catalog: CatalogPlugin[], options: { replacePluginId?: string; - client?: DaemonClient; - } = {}, + client: DaemonClient; + }, ): boolean { const previous = this.byHost.get(serverId) ?? []; const previousTimelineBundles = previous @@ -58,6 +58,7 @@ class PluginRegistry { const installed = catalog.flatMap((entry) => { const key = `${serverId}/${entry.id}`; try { + if (!entry.clientBundle) return []; const existing = preserved.find( (plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle, ); @@ -65,29 +66,29 @@ class PluginRegistry { this.evaluationErrors.delete(key); return [existing]; } - const queryClient = new QueryClient(); - const evaluated = evaluatePluginClientBundle(entry.id, entry.clientBundle); const installation: InstalledPlugin = { - ...evaluated, + id: entry.id, serverId, clientBundle: entry.clientBundle, - queryClient, + queryClient: new QueryClient(), + cleanup: () => undefined, + surfaces: [], + sidebarItems: [], + workspacePanels: [], + commandCenterItems: [], + clientSlashCommands: [], + attachmentSources: [], + themes: [], + timelineTransformers: [], + timelineRenderers: [], }; - if (installation.clientSide) { - if (!options.client) throw new Error("Plugin client runtime is unavailable"); - let clientCleanup; - try { - clientCleanup = startPluginClientSide(installation, options.client); - } catch (error) { - queryClient.clear(); - void Promise.resolve(evaluated.cleanup()).catch(() => undefined); - throw error; - } - installation.cleanup = async () => { - await clientCleanup(); - await evaluated.cleanup(); - }; - } + const evaluated = runPluginClientBundle( + entry.id, + entry.clientBundle, + createPluginClientRuntime(installation, options.client), + () => this.publish(), + ); + Object.assign(installation, evaluated); this.evaluationErrors.delete(key); return [installation]; } catch (error) { diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index 907c9531cd..dbecea7662 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -21,7 +21,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin { ], workspacePanels: [], commandCenterItems: [], - clientSide: null, + clientSlashCommands: [], attachmentSources: [], themes: [], timelineTransformers: [], diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 66e897f0fd..653bac17bd 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -26,7 +26,7 @@ function installation( })), workspacePanels: [], commandCenterItems: [], - clientSide: null, + clientSlashCommands: [], attachmentSources: [], themes: [], timelineTransformers: [], diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index f0f5882bef..6081a33e86 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -54,7 +54,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install sidebarItems: [], workspacePanels: [], commandCenterItems: [], - clientSide: null, + clientSlashCommands: [], attachmentSources: [], themes, timelineTransformers: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index c08b1380f7..27e734d40d 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -17,7 +17,7 @@ function plugin(input: { sidebarItems: [], workspacePanels: [], commandCenterItems: [], - clientSide: null, + clientSlashCommands: [], attachmentSources: [], themes: [], timelineTransformers: [ diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index fd253a7a20..870a51742b 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -17,6 +17,15 @@ vi.mock("@/constants/layout", () => ({ vi.mock("../navigation", () => ({ createPluginNavigation: () => ({}), })); +vi.mock("../client-runtime", () => ({ + createPluginClientRuntime: () => ({ + paseo: {}, + rpc: async () => undefined, + openSurface: () => undefined, + openPanel: () => undefined, + addComposerPill: () => () => undefined, + }), +})); vi.mock("../icons", () => ({ Icon: () => null, resolvePluginIcon: () => () => null, @@ -96,6 +105,7 @@ const recoveredTimelineItem = { ...timelineItem, data: { label: "Recovered" } }; const roots: Array> = []; const containers: HTMLElement[] = []; +const daemonClient = {} as DaemonClient; beforeEach(() => { vi.stubGlobal("React", React); @@ -111,7 +121,9 @@ afterEach(() => { describe("PluginTimelineItemView", () => { it("validates and renders the matching plugin component", () => { - pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: bundle }]); + pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: bundle }], { + client: daemonClient, + }); const markup = renderToStaticMarkup( , @@ -123,7 +135,9 @@ describe("PluginTimelineItemView", () => { it("contains renderer crashes to one timeline item", async () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined); - pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: failingBundle }]); + pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: failingBundle }], { + client: daemonClient, + }); const container = document.createElement("div"); containers.push(container); document.body.appendChild(container); @@ -148,7 +162,9 @@ describe("PluginTimelineItemView", () => { it("recovers when a streaming item's data changes", async () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined); - pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: recoveringBundle }]); + pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: recoveringBundle }], { + client: daemonClient, + }); const container = document.createElement("div"); containers.push(container); document.body.appendChild(container); diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index b7881bf769..0006fe553a 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -2,7 +2,6 @@ import type { QueryClient } from "@tanstack/react-query"; import type { PluginAttachmentSourceContribution, PluginCommandCenterItemContribution, - PluginClientContribution, PluginClientSlashCommandContribution, PluginCleanup, PluginComposerPillContribution, @@ -26,8 +25,7 @@ export interface EvaluatedPlugin { sidebarItems: PluginSidebarContribution[]; workspacePanels: EvaluatedPluginWorkspacePanelContribution[]; commandCenterItems: PluginCommandCenterItemContribution[]; - clientSlashCommands?: PluginClientSlashCommandContribution[]; - clientSide: PluginClientContribution | null; + clientSlashCommands: PluginClientSlashCommandContribution[]; attachmentSources: PluginAttachmentSourceContribution[]; themes: PluginThemeContribution[]; timelineTransformers: PluginTimelineTransformerContribution[]; diff --git a/packages/app/src/plugins/workspace-panels/locations.test.ts b/packages/app/src/plugins/workspace-panels/locations.test.ts index 1efbc134c6..d0e69478da 100644 --- a/packages/app/src/plugins/workspace-panels/locations.test.ts +++ b/packages/app/src/plugins/workspace-panels/locations.test.ts @@ -1,7 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { pluginRegistry } from "../registry"; import { panelTargetSupportsHost, resolvePluginPanelOpenLocation } from "./locations"; +vi.mock("../navigation", () => ({ + createPluginNavigation: () => ({}), +})); +vi.mock("../client-runtime", () => ({ + createPluginClientRuntime: () => ({ + paseo: {}, + rpc: async () => undefined, + openSurface: () => undefined, + openPanel: () => undefined, + addComposerPill: () => () => undefined, + }), +})); + function install(locations: readonly ("workspace" | "explorer")[]) { const bundle = `(function() { return { default: function(plugin) { @@ -16,7 +30,9 @@ function install(locations: readonly ("workspace" | "explorer")[]) { return function() {}; }}; })`; - pluginRegistry.installCatalog("host-1", [{ id: "review", clientBundle: bundle }]); + pluginRegistry.installCatalog("host-1", [{ id: "review", clientBundle: bundle }], { + client: {} as DaemonClient, + }); return pluginRegistry.getSnapshot()[0]!; } diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index 38e6e9fdb1..8ca204b8ba 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -23,7 +23,7 @@ function installed(): InstalledPlugin { }, ], commandCenterItems: [], - clientSide: null, + clientSlashCommands: [], attachmentSources: [], themes: [], timelineTransformers: [], diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index a565ea7d82..135286175d 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -1,11 +1,27 @@ +import { execFile } from "node:child_process"; import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { tmpdir } from "node:os"; -import ts from "typescript"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { scaffoldPluginDirectory } from "./scaffold.js"; const directories: string[] = []; +const execFileAsync = promisify(execFile); +const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc"); + +async function typecheckPlugin(directory: string): Promise { + const configPath = path.join(directory, "tsconfig.json"); + try { + await execFileAsync(process.execPath, [tscPath, "--noEmit", "-p", configPath], { + cwd: directory, + }); + } catch (error) { + const failure = error as { stderr?: string; stdout?: string }; + throw new Error(`${failure.stdout ?? ""}${failure.stderr ?? ""}`, { cause: error }); + } +} afterEach(async () => { await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); @@ -20,17 +36,12 @@ describe("plugin scaffold", () => { await scaffoldPluginDirectory(directory); const configPath = path.join(directory, "tsconfig.json"); - const loaded = ts.readConfigFile(configPath, ts.sys.readFile); - expect(loaded.error).toBeUndefined(); - const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, directory); - const diagnostics = ts.getPreEmitDiagnostics( - ts.createProgram(parsed.fileNames, parsed.options), - ); - expect( - diagnostics.map((diagnostic) => - ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), - ), - ).toEqual([]); + const config = JSON.parse(await readFile(configPath, "utf8")) as { + compilerOptions: { lib: string[]; types: string[] }; + }; + expect(config.compilerOptions.lib).toEqual(["ES2023"]); + expect(config.compilerOptions.types).toEqual(["react"]); + await expect(typecheckPlugin(directory)).resolves.toBeUndefined(); expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({ id: "hello-plugin", }); @@ -53,12 +64,38 @@ describe("plugin scaffold", () => { }, }); expect(await readdir(directory)).not.toContain("paseo-plugin.d.ts"); - await expect(readFile(path.join(directory, "index.ts"), "utf8")).resolves.toContain( - 'from "./main.client"', + await expect(readFile(path.join(directory, "index.client.tsx"), "utf8")).resolves.toContain( + 'from "./client/greeting"', ); - await expect(readFile(path.join(directory, "main.client.tsx"), "utf8")).resolves.toContain( - "Hello from my plugin", + await expect(readFile(path.join(directory, "index.server.ts"), "utf8")).resolves.toContain( + 'from "./server/greeting"', ); + await expect(readFile(path.join(directory, "client/greeting.tsx"), "utf8")).resolves.toContain( + "useRpc(greetingRpc)", + ); + await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.not.toContain( + `/// `, + ); + await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.toContain( + `declare const window: { open(url: string, target: string, features: string): unknown };`, + ); + await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.toContain( + `Platform.OS === "web"`, + ); + await expect(readFile(path.join(directory, "client/greeting.tsx"), "utf8")).resolves.toContain( + `openExternal("https://paseo.sh")`, + ); + await expect(readFile(path.join(directory, "server/greeting.ts"), "utf8")).resolves.toContain( + '"Hello, " + name + "!"', + ); + await expect(readFile(path.join(directory, "shared/greeting.ts"), "utf8")).resolves.toContain( + 'name: "greeting.create"', + ); + + await writeFile(path.join(directory, "client/greeting.tsx"), '\ndocument.title = "x";\n', { + flag: "a", + }); + await expect(typecheckPlugin(directory)).rejects.toThrow("Cannot find name 'document'."); }); it("typechecks client and server Paseo API access", async () => { @@ -68,8 +105,8 @@ describe("plugin scaffold", () => { await scaffoldPluginDirectory(directory); await Promise.all([ writeFile( - path.join(directory, "inspect.shared.ts"), - `import { defineRpc } from "@getpaseo/plugin/server"; + path.join(directory, "shared", "inspect.ts"), + `import { defineRpc } from "@getpaseo/plugin"; import { z } from "zod"; export const inspect = defineRpc({ @@ -80,13 +117,13 @@ export const inspect = defineRpc({ `, ), writeFile( - path.join(directory, "inspect.server.ts"), + path.join(directory, "server", "inspect.ts"), `import type { PluginHandlerContext } from "@getpaseo/plugin/server"; -import type { output as ZodOutput } from "zod"; -import { inspect } from "./inspect.shared"; +import type { RpcInput } from "@getpaseo/plugin"; +import { inspect } from "../shared/inspect"; export async function inspectConfig( - _input: ZodOutput, + _input: RpcInput, { paseo }: PluginHandlerContext, ) { return { configured: Boolean((await paseo.config.get()).config) }; @@ -94,7 +131,7 @@ export async function inspectConfig( `, ), writeFile( - path.join(directory, "main.client.tsx"), + path.join(directory, "client", "main.tsx"), `import React from "react"; import { Text } from "react-native"; import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native"; @@ -107,7 +144,7 @@ import { usePaseo, useWorkspace, } from "@getpaseo/plugin"; -import { inspect } from "./inspect.shared"; +import { inspect } from "../shared/inspect"; export function Surface({ navigation }: PluginSurfaceProps) { const paseo = usePaseo(); @@ -155,23 +192,21 @@ export function contributeClient(client: PluginClientContext) { `, ), writeFile( - path.join(directory, "index.ts"), - `import type { PluginContext } from "@getpaseo/plugin"; -import { AgentPanel, contributeClient, Surface } from "./main.client"; -import { inspectConfig } from "./inspect.server"; -import { inspect } from "./inspect.shared"; + path.join(directory, "index.client.tsx"), + `import type { PluginClientContext } from "@getpaseo/plugin"; +import { AgentPanel, contributeClient, Surface } from "./client/main"; +import { inspect } from "./shared/inspect"; -export default function contribute(plugin: PluginContext) { - plugin.handle(inspect, inspectConfig); - plugin.addSurface("main", Surface); - plugin.addWorkspacePanel({ +export default function contribute(client: PluginClientContext) { + client.addSurface("main", Surface); + client.addWorkspacePanel({ id: "review", title: "Review", icon: "Scan", context: "agent", Component: AgentPanel, }); - plugin.addCommandCenterItem({ + client.addCommandCenterItem({ id: "open-review", title: "Open review", icon: "Scan", @@ -182,25 +217,25 @@ export default function contribute(plugin: PluginContext) { openPanel("review"); }, }); - plugin.addClientSide(contributeClient); + return contributeClient(client); +} +`, + ), + writeFile( + path.join(directory, "index.server.ts"), + `import type { PluginServerContext } from "@getpaseo/plugin"; +import { inspectConfig } from "./server/inspect"; +import { inspect } from "./shared/inspect"; + +export default function contribute(server: PluginServerContext) { + server.handle(inspect, inspectConfig); return () => {}; } `, ), ]); - const configPath = path.join(directory, "tsconfig.json"); - const loaded = ts.readConfigFile(configPath, ts.sys.readFile); - const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, directory); - const diagnostics = ts.getPreEmitDiagnostics( - ts.createProgram(parsed.fileNames, parsed.options), - ); - - expect( - diagnostics.map((diagnostic) => - ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), - ), - ).toEqual([]); + await expect(typecheckPlugin(directory)).resolves.toBeUndefined(); }, 20_000); it("refuses to write into a non-empty directory", async () => { diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index dbeab48cce..e57e316215 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -8,7 +8,8 @@ const TSCONFIG = { target: "ES2020", module: "ESNext", moduleResolution: "Bundler", - lib: ["ES2023", "DOM"], + lib: ["ES2023"], + types: ["react"], jsx: "react-jsx", strict: true, skipLibCheck: true, @@ -19,20 +20,60 @@ const TSCONFIG = { include: ["**/*.ts", "**/*.tsx"], }; -const ENTRY = `import type { PluginContext } from "@getpaseo/plugin"; -import { MainSurface } from "./main.client"; +const CLIENT_ENTRY = `import type { PluginClientContext } from "@getpaseo/plugin"; +import { GreetingSurface } from "./client/greeting"; -export default function contribute(plugin: PluginContext) { - plugin.addSurface("main", MainSurface); +export default function contribute(client: PluginClientContext) { + client.addSurface("greeting", GreetingSurface); + client.addSidebarItem({ + id: "greeting", + title: "Greeting", + icon: "MessageCircle", + surface: "greeting", + }); return () => {}; } `; -const CLIENT_SURFACE = `import type { PluginSurfaceProps } from "@getpaseo/plugin"; -import React, { useMemo } from "react"; -import { Text, View } from "react-native"; +const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin"; +import { createGreeting } from "./server/greeting"; +import { greetingRpc } from "./shared/greeting"; -export function MainSurface({ theme, layout }: PluginSurfaceProps) { +export default function contribute(server: PluginServerContext) { + server.handle(greetingRpc, createGreeting); + return () => {}; +} +`; + +const SHARED_GREETING = `import { defineRpc } from "@getpaseo/plugin"; +import { z } from "zod"; + +export const greetingRpc = defineRpc({ + name: "greeting.create", + input: z.object({ name: z.string() }), + output: z.object({ message: z.string() }), +}); +`; + +const SERVER_GREETING = `import type { RpcInput } from "@getpaseo/plugin"; +import { greetingRpc } from "../shared/greeting"; + +export function createGreeting({ name }: RpcInput) { + return { message: "Hello, " + name + "!" }; +} +`; + +const CLIENT_GREETING = `import type { PluginSurfaceProps } from "@getpaseo/plugin"; +import { useRpc } from "@getpaseo/plugin"; +import { useMutation } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { Pressable, Text, View } from "react-native"; +import { greetingRpc } from "../shared/greeting"; +import { openExternal } from "./web"; + +export function GreetingSurface({ theme, layout }: PluginSurfaceProps) { + const createGreeting = useRpc(greetingRpc); + const greeting = useMutation({ mutationFn: createGreeting }); const styles = useMemo( () => ({ screen: { @@ -41,17 +82,49 @@ export function MainSurface({ theme, layout }: PluginSurfaceProps) { backgroundColor: theme.colors.surface0, }, text: { color: theme.colors.foreground }, + button: { padding: 12, backgroundColor: theme.colors.accent }, + buttonText: { color: theme.colors.accentForeground }, }), [theme, layout.compact], ); return ( - Hello from my plugin + {greeting.data?.message ?? "Ask the daemon for a greeting."} + greeting.mutate({ name: "Paseo" })} + > + Create greeting + + openExternal("https://paseo.sh")} + > + Open paseo.sh + ); } `; +const CLIENT_WEB = `import { Linking, Platform } from "react-native"; + +// This plugin typechecks without the DOM library. Declare only what this module uses. +declare const window: { open(url: string, target: string, features: string): unknown }; + +export async function openExternal(url: string): Promise { + if (Platform.OS === "web") { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + await Linking.openURL(url); +} +`; + export interface PluginScaffold { id: string; directory: string; @@ -88,9 +161,16 @@ export async function scaffoldPluginDirectory( ["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`], ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], ["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`], - ["index.ts", ENTRY], - ["main.client.tsx", CLIENT_SURFACE], + ["index.client.tsx", CLIENT_ENTRY], + ["index.server.ts", SERVER_ENTRY], + ["shared/greeting.ts", SHARED_GREETING], + ["server/greeting.ts", SERVER_GREETING], + ["client/greeting.tsx", CLIENT_GREETING], + ["client/web.ts", CLIENT_WEB], ]); + await Promise.all( + ["shared", "server", "client"].map((name) => mkdir(path.join(directory, name))), + ); await Promise.all( [...files].map(([filename, contents]) => writeFile(path.join(directory, filename), contents, { flag: "wx" }), diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index bebc675405..afc28c551c 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -27,7 +27,7 @@ async function main(): Promise { const context = await createE2ETestContext({ timeout: 45_000 }); try { await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "cli-e2e" })); - await writeFile(path.join(directory, "index.tsx"), pluginSource); + await writeFile(path.join(directory, "index.server.ts"), pluginSource); const install = await context.paseo(["plugin", "install", directory, "--json"]); assert.equal(install.exitCode, 0, install.stderr); @@ -44,7 +44,7 @@ async function main(): Promise { path.join(gitDirectory, "paseo-plugin.json"), JSON.stringify({ id: "git-cli-e2e" }), ); - await writeFile(path.join(gitDirectory, "index.ts"), pluginSource); + await writeFile(path.join(gitDirectory, "index.server.ts"), pluginSource); await git(gitDirectory, ["add", "-A"]); await git(gitDirectory, ["commit", "-m", "initial"]); @@ -58,7 +58,7 @@ async function main(): Promise { assert.equal(JSON.parse(gitInstall.stdout).source, "git"); await writeFile( - path.join(gitDirectory, "index.ts"), + path.join(gitDirectory, "index.server.ts"), `${pluginSource}\nconst updated = true;\n`, ); await git(gitDirectory, ["add", "-A"]); diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 91606cea0b..1e5e10ed3c 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -580,7 +580,19 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, const responsiveViewport = await readViewport(client, browserId); await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click(); - await page.waitForTimeout(500); + await page.waitForFunction( + ({ id, webContentsId }) => { + const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); + return ( + webview?.parentElement?.getAttribute("data-paseo-browser-surface") === id && + webview.parentElement.style.width === "1px" && + webview.parentElement.style.pointerEvents === "none" && + webview.getWebContentsId() === webContentsId + ); + }, + { id: browserId, webContentsId: firstGuest.webContentsId }, + { timeout: timeoutMs }, + ); try { await callBrowserToolUntilReady(client, "browser_screenshot", { browserId }); } catch (error) { diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index bf0303b658..dbe5116161 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -127,7 +127,20 @@ export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions { } export interface PluginClientContext extends PluginCommandCapabilities { + addSurface(id: string, Component: ComponentType): 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( + contribution: PluginTimelineTransformerContribution, + ): PluginCleanup; + addTimelineRenderer( + contribution: PluginTimelineRendererContribution, + ): PluginCleanup; openPanel(id: string, options: PluginClientOpenPanelOptions): void; } @@ -296,7 +309,7 @@ export interface PluginHandlerContext { paseo: PaseoApi; } -export interface PluginContext { +export interface PluginServerContext { handle( contract: PluginRpcContract, handler: ( @@ -304,22 +317,8 @@ export interface PluginContext { context: PluginHandlerContext, ) => ZodInput | Promise>, ): void; - addSurface(id: string, Component: ComponentType): void; - addSidebarItem(contribution: PluginSidebarContribution): void; - addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void; - addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void; - addClientSlashCommand(contribution: PluginClientSlashCommandContribution): void; - addClientSide(contribution: PluginClientContribution): void; - addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; - addTheme(contribution: PluginThemeContribution): void; - addTimelineTransformer( - contribution: PluginTimelineTransformerContribution, - ): void; - addTimelineRenderer( - contribution: PluginTimelineRendererContribution, - ): void; } export type PluginCleanup = () => void | Promise; -export type PluginContribution = (plugin: PluginContext) => PluginCleanup; +export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup; diff --git a/packages/plugin/src/host.test.ts b/packages/plugin/src/host.test.ts index bc1eac2d01..58aab65603 100644 --- a/packages/plugin/src/host.test.ts +++ b/packages/plugin/src/host.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod"; -import { defineAttachmentSource, defineRpc } from "./index.js"; +import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "./index.js"; import { callPluginRpc, searchPluginAttachments } from "./host.js"; const contract = defineRpc({ @@ -10,6 +10,11 @@ const contract = defineRpc({ }); describe("plugin RPC contracts", () => { + it("derives handler input and output types from a contract", () => { + expectTypeOf>().toEqualTypeOf<{ value: number }>(); + expectTypeOf>().toEqualTypeOf<{ value: number }>(); + }); + it("validates input before invoking the daemon", async () => { let invocations = 0; const invoke = async () => { diff --git a/packages/plugin/src/host.ts b/packages/plugin/src/host.ts index fea1865c25..6fd4d7aaac 100644 --- a/packages/plugin/src/host.ts +++ b/packages/plugin/src/host.ts @@ -4,99 +4,10 @@ export { usePluginRuntimeContextBridge, type PluginRuntimeContextBridge, } from "./runtime-context-bridge.js"; -import type { - PluginAttachmentSourceContribution, - PluginCommandCenterItemContribution, - PluginClientContribution, - PluginClientSlashCommandContribution, - PluginContext, - PluginSidebarContribution, - PluginSurfaceContribution, - PluginSurfaceProps, - PluginThemeContribution, - PluginTimelineRendererContribution, - PluginTimelineTransformerContribution, - PluginWorkspacePanelContribution, -} 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 type { ComponentType } from "react"; - -interface PluginCollector { - addSurface(id: string, Component: ComponentType): void; - addSidebarItem(contribution: PluginSidebarContribution): void; - addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void; - addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void; - addClientSlashCommand(contribution: PluginClientSlashCommandContribution): void; - addClientSide(contribution: PluginClientContribution): void; - addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; - addTheme(contribution: PluginThemeContribution): void; - addTimelineTransformer(contribution: PluginTimelineTransformerContribution): void; - addTimelineRenderer(contribution: PluginTimelineRendererContribution): void; -} - -export interface PluginRegistrationCollector { - surfaces: PluginSurfaceContribution[]; - sidebarItems: PluginSidebarContribution[]; - workspacePanels: PluginWorkspacePanelContribution[]; - commandCenterItems: PluginCommandCenterItemContribution[]; - clientSlashCommands: PluginClientSlashCommandContribution[]; - clientSide: PluginClientContribution | null; - attachmentSources: PluginAttachmentSourceContribution[]; - themes: PluginThemeContribution[]; - timelineTransformers: PluginTimelineTransformerContribution[]; - timelineRenderers: PluginTimelineRendererContribution[]; -} - -export function createPluginContext( - collector: PluginCollector, -): Pick< - PluginContext, - | "addSurface" - | "addSidebarItem" - | "addWorkspacePanel" - | "addCommandCenterItem" - | "addClientSlashCommand" - | "addClientSide" - | "addAttachmentSource" - | "addTheme" - | "addTimelineTransformer" - | "addTimelineRenderer" -> { - return { - addSurface(id, Component) { - collector.addSurface(id, Component); - }, - addSidebarItem(contribution) { - collector.addSidebarItem(contribution); - }, - addWorkspacePanel(contribution) { - collector.addWorkspacePanel(contribution); - }, - addCommandCenterItem(contribution) { - collector.addCommandCenterItem(contribution); - }, - addClientSlashCommand(contribution) { - collector.addClientSlashCommand(contribution); - }, - addClientSide(contribution) { - collector.addClientSide(contribution); - }, - addAttachmentSource(contribution) { - collector.addAttachmentSource(contribution); - }, - addTheme(contribution) { - collector.addTheme(contribution); - }, - addTimelineTransformer(contribution) { - collector.addTimelineTransformer(contribution); - }, - addTimelineRenderer(contribution) { - collector.addTimelineRenderer(contribution); - }, - }; -} export async function searchPluginAttachments( source: PluginAttachmentSourceContribution, diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 986d24db11..e54e24e85b 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -1,15 +1,19 @@ import type { ComponentType } from "react"; -import type { PluginIconProps } from "./contracts.js"; +import type { PluginAttachmentSourceContribution, PluginIconProps } from "./contracts.js"; export { PluginAttachmentItemSchema, PluginAttachmentSearchPayloadSchema, - defineAttachmentSource, - defineRpc, type PluginAttachmentItem, type PluginAttachmentSearchPayload, - type PluginRpcContract, -} from "./server.js"; +} from "./attachments.js"; +export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js"; + +export function defineAttachmentSource( + definition: Definition, +): Definition { + return definition; +} export declare const Icon: ComponentType; export type { @@ -26,8 +30,8 @@ export type { PluginClientOpenPanelOptions, PluginComposerPillContribution, PluginComposerPillProps, - PluginContribution, - PluginContext, + PluginServerContribution, + PluginServerContext, PluginGlobalCommandContext, PluginHandlerContext, PluginHostProps, diff --git a/packages/plugin/src/migration-doc.test.ts b/packages/plugin/src/migration-doc.test.ts new file mode 100644 index 0000000000..829ce110f6 --- /dev/null +++ b/packages/plugin/src/migration-doc.test.ts @@ -0,0 +1,23 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +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 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]); + const migration = await readFile( + fileURLToPath(new URL("../../../public-docs/plugins/migration.md", import.meta.url)), + "utf8", + ); + const table = migration.slice( + migration.indexOf("| Old registration and location"), + migration.indexOf("## 4. Separate imports"), + ); + + expect(methods).not.toEqual([]); + for (const method of methods) expect(table).toContain(`client.${method}(`); + }); +}); diff --git a/packages/plugin/src/rpc.ts b/packages/plugin/src/rpc.ts index fab69276d4..6a16a19691 100644 --- a/packages/plugin/src/rpc.ts +++ b/packages/plugin/src/rpc.ts @@ -9,6 +9,9 @@ export interface PluginRpcContract< output: OutputSchema; } +export type RpcInput = ZodOutput; +export type RpcOutput = ZodInput; + interface RpcDefinition { name: string; input: InputSchema; diff --git a/packages/plugin/src/server.ts b/packages/plugin/src/server.ts index 1260b62718..8aa3325a83 100644 --- a/packages/plugin/src/server.ts +++ b/packages/plugin/src/server.ts @@ -1,16 +1 @@ -import type { PluginAttachmentSourceContribution } from "./contracts.js"; - -export { - PluginAttachmentItemSchema, - PluginAttachmentSearchPayloadSchema, - type PluginAttachmentItem, - type PluginAttachmentSearchPayload, -} from "./attachments.js"; -export type { PluginAttachmentSourceContribution, PluginHandlerContext } from "./contracts.js"; -export { defineRpc, type PluginRpcContract } from "./rpc.js"; - -export function defineAttachmentSource( - definition: Definition, -): Definition { - return definition; -} +export type { PluginHandlerContext } from "./contracts.js"; diff --git a/packages/plugin/tsconfig.examples.json b/packages/plugin/tsconfig.examples.json index 7aa081b897..59a2b702c8 100644 --- a/packages/plugin/tsconfig.examples.json +++ b/packages/plugin/tsconfig.examples.json @@ -3,7 +3,7 @@ "target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler", - "lib": ["ES2023", "DOM"], + "lib": ["ES2023"], "jsx": "react-jsx", "strict": true, "skipLibCheck": true, diff --git a/packages/server/package.json b/packages/server/package.json index 5febf91df8..cd00102b58 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -71,7 +71,6 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@babel/parser": "^7.29.8", "@getpaseo/client": "0.7.2", "@getpaseo/highlight": "0.7.2", "@getpaseo/plugin": "0.7.2", diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 54fb1e21d8..40ea69e863 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -675,7 +675,7 @@ describe("paseo daemon bootstrap", () => { JSON.stringify({ id: "startup-rollback" }), ); await writeFile( - path.join(pluginDirectory, "index.tsx"), + path.join(pluginDirectory, "index.server.ts"), `import { writeFileSync } from "node:fs"; export default function contribute(plugin: unknown) { void plugin; diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index 9bb237ad2f..a922403aa7 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -9,6 +9,17 @@ import { } from "./compiler.js"; const asarEsbuildDir = path.join("Resources", "app.asar", "node_modules", "esbuild"); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => + rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), + ), + ); +}); describe("asar esbuild binary resolution", () => { it("rewrites an asar package path to the unpacked platform binary", () => { @@ -64,159 +75,432 @@ describe("asar esbuild binary resolution", () => { }); }); -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all( - temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), - ); -}); - -describe("plugin author module externals", () => { - // COMPAT(plugin-sdk-scope): plugins written against the unpublished @paseo/plugin name must - // keep compiling. Drop that case with the specifiers in plugin-sdk-specifiers.ts. - it.each(["@getpaseo/plugin", "@paseo/plugin"])( - "leaves %s/server external in both bundles", - async (sdk) => { - const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-")); - temporaryDirectories.push(directory); - const entryPath = path.join(directory, "index.ts"); - await writeFile( - entryPath, - `import type { PluginContext } from "${sdk}"; -import { Icon } from "${sdk}/react-native"; -import { defineRpc } from "${sdk}/server"; -import { z } from "zod"; - -const ping = defineRpc({ - name: "ping", - input: z.object({}), - output: z.object({ ok: z.boolean() }), -}); - -function Surface() { - return Icon({ name: "Settings", size: 18 }); -} - -export default function contribute(plugin: PluginContext) { - plugin.handle(ping, async () => ({ ok: true })); - plugin.addSurface("main", Surface); - return () => undefined; -} -`, - ); - - const { clientBundle, serverBundle } = await compilePlugin(entryPath); - expect(clientBundle).toContain(`${sdk}/react-native`); - expect(clientBundle).toContain(`${sdk}/server`); - expect(serverBundle).toContain(`${sdk}/server`); - expect(serverBundle).not.toContain(`${sdk}/react-native`); - expect(clientBundle).toContain("Settings"); - expect(serverBundle).not.toContain("Settings"); - expect(clientBundle).not.toContain("Invalid plugin RPC method"); - expect(serverBundle).not.toContain("Invalid plugin RPC method"); - }, - ); -}); - -describe("plugin contribution targets", () => { - it("keeps client contributions out of the server bundle", async () => { - const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-")); - temporaryDirectories.push(directory); - const entryPath = path.join(directory, "index.ts"); - await writeFile( - entryPath, - `export default function contribute(plugin) { - plugin.addTimelineTransformer({ - id: "timeline-card", - query: { itemType: "tool_call" }, - transform() { return { items: [] }; }, - }); - plugin.addTimelineRenderer({ - kind: "timeline-card", - version: 1, - schema: { safeParse(value) { return { success: true, data: value }; } }, - Component() { return null; }, - }); - plugin.addClientSlashCommand({ - name: "review", - description: "Review changes", - argumentHint: "[scope]", - context: "agent", - onSubmit() {}, - }); - plugin.addClientSide((client) => { - return client.addComposerPill({ - id: "composer-card", - title: "Composer card", - workspaceId: "workspace-a", - agentId: "agent-a", - Component() { return null; }, - onPress() {}, - }); - }); - return () => undefined; -} -`, - ); - - const { clientBundle, serverBundle } = await compilePlugin(entryPath); - expect(clientBundle).toContain("timeline-card"); - expect(clientBundle).toContain("composer-card"); - expect(clientBundle).toContain("Review changes"); - expect(serverBundle).not.toContain("timeline-card"); - expect(serverBundle).not.toContain("composer-card"); - expect(serverBundle).not.toContain("Review changes"); - }); -}); - -describe("plugin client runtime syntax", () => { - it("uses the automatic JSX runtime without a React import", async () => { - const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-")); - temporaryDirectories.push(directory); - const entryPath = path.join(directory, "index.tsx"); - await writeFile( - entryPath, +async function createSplitPlugin(): Promise<{ + directory: string; + client: string; + server: string; +}> { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-")); + temporaryDirectories.push(directory); + await Promise.all([ + mkdir(path.join(directory, "client")), + mkdir(path.join(directory, "server")), + mkdir(path.join(directory, "shared")), + ]); + const client = path.join(directory, "index.client.tsx"); + const server = path.join(directory, "index.server.ts"); + await Promise.all([ + writeFile( + path.join(directory, "shared", "labels.ts"), + `export const clientLabel = "Client contribution"; +export const serverLabel = "Server contribution";`, + ), + writeFile( + path.join(directory, "client", "surface.tsx"), `import { Text } from "react-native"; - -function Surface() { - return Automatic JSX; -} - -export default function contribute(plugin) { - plugin.addSurface("automatic-jsx", Surface); +import { clientLabel } from "../shared/labels"; +export function Surface() { return {clientLabel}; }`, + ), + writeFile( + path.join(directory, "server", "handler.ts"), + `import { serverLabel } from "../shared/labels"; +export function handler() { return { label: serverLabel }; }`, + ), + writeFile( + client, + `import { Surface } from "./client/surface"; +export default function contribute(client) { + client.addSurface("main", Surface); return () => undefined; +}`, + ), + writeFile( + server, + `import { handler } from "./server/handler"; +export default function contribute(server) { + server.handle({ name: "probe" }, handler); + return () => undefined; +}`, + ), + ]); + return { directory, client, server }; } -`, - ); - const { clientBundle } = await compilePlugin(entryPath); +describe("plugin runtime entries", () => { + it("builds each runtime from its own entry and shares neutral modules", async () => { + const entries = await createSplitPlugin(); + + const { clientBundle, serverBundle } = await compilePlugin(entries); + + expect(clientBundle).toContain("Client contribution"); + expect(clientBundle).not.toContain("Server contribution"); + expect(serverBundle).toContain("Server contribution"); + expect(serverBundle).not.toContain("Client contribution"); + }); + + it("uses the automatic JSX runtime without a React import", async () => { + const entries = await createSplitPlugin(); + const { clientBundle } = await compilePlugin(entries); expect(clientBundle).toContain("react/jsx-runtime"); expect(clientBundle).not.toContain("React.createElement"); }); it("lowers async callbacks before Hermes evaluates the client bundle", async () => { - const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-")); - temporaryDirectories.push(directory); - const entryPath = path.join(directory, "index.tsx"); + const entries = await createSplitPlugin(); await writeFile( - entryPath, - `import type { PluginContext } from "@getpaseo/plugin"; - -export default function contribute(plugin: PluginContext) { - plugin.addSurface("probe", () => { - const refresh = async () => "ready"; - return refresh; - }); - plugin.handle({ name: "probe" } as never, async () => "ready"); + entries.client, + `export default async function contribute() { + await Promise.resolve(); return () => undefined; -} -`, +}`, + ); + const { clientBundle } = await compilePlugin(entries); + expect(clientBundle).not.toContain("async function contribute"); + expect(clientBundle).toContain("__async"); + }); + + it("rejects node imports from the client entry", async () => { + const entries = await createSplitPlugin(); + await writeFile( + entries.client, + `import { readFile } from "node:fs"; +export default function contribute() { void readFile; return () => undefined; }`, + ); + await expect(compilePlugin(entries)).rejects.toThrow( + "Node module cannot be imported into the plugin client bundle: node:fs", + ); + }); + + it("rejects server directory imports from the client bundle", async () => { + const entries = await createSplitPlugin(); + await writeFile( + entries.client, + `import { handler } from "./server/handler"; +export default function contribute() { void handler; return () => undefined; }`, + ); + await expect(compilePlugin(entries)).rejects.toThrow( + "server-only module cannot be imported into the plugin client bundle", + ); + }); + + it("rejects client directory imports from the server bundle", async () => { + const entries = await createSplitPlugin(); + await writeFile( + entries.server, + `import { Surface } from "./client/surface"; +export default function contribute() { void Surface; return () => undefined; }`, + ); + await expect(compilePlugin(entries)).rejects.toThrow( + "client-only module cannot be imported into the plugin server bundle", + ); + }); + + it("rejects modules imported directly from the plugin root", async () => { + const entries = await createSplitPlugin(); + const helper = path.join(entries.directory, "helper.ts"); + await writeFile(helper, "export const value = 1;"); + await writeFile( + entries.server, + `import { value } from "./helper"; +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")}`, + ); + }); + + it("rejects relative imports that escape the plugin root", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-parent-")); + temporaryDirectories.push(parent); + const pluginDirectory = path.join(parent, "plugin"); + const server = path.join(pluginDirectory, "index.server.ts"); + await mkdir(pluginDirectory); + await Promise.all([ + writeFile(path.join(parent, "secret.ts"), `export const secret = "outside";`), + writeFile( + server, + `import { secret } from "../secret"; +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")}`, + ); + }); + + it("rejects absolute imports from outside the plugin root", async () => { + const entries = await createSplitPlugin(); + const outsideDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-outside-")); + temporaryDirectories.push(outsideDirectory); + const outside = path.join(outsideDirectory, "secret.ts"); + await writeFile(outside, `export const secret = "outside";`); + await writeFile( + entries.client, + `import { secret } from ${JSON.stringify(outside)}; +export default function contribute() { void secret; return () => undefined; }`, ); - const { clientBundle, serverBundle } = await compilePlugin(entryPath); - expect(clientBundle).not.toContain("async () =>"); - expect(clientBundle).toContain("__async"); - expect(serverBundle).toContain("async () =>"); + await expect(compilePlugin(entries)).rejects.toThrow( + `Plugin modules belong in client/, server/, or shared/: ${outside}`, + ); + }); + + it("rejects plugin-authored relative imports that escape into node_modules", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-node-modules-parent-")); + temporaryDirectories.push(parent); + const pluginDirectory = path.join(parent, "plugin"); + const server = path.join(pluginDirectory, "index.server.ts"); + const outside = path.join(parent, "node_modules", "secret.ts"); + await Promise.all([mkdir(pluginDirectory), mkdir(path.dirname(outside))]); + await Promise.all([ + writeFile(outside, `export const secret = "outside";`), + writeFile( + server, + `import { secret } from "../node_modules/secret"; +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")}`, + ); + }); + + it("keeps nested modules owned by their top-level runtime directory", async () => { + const entries = await createSplitPlugin(); + const nestedClient = path.join(entries.directory, "client", "feature", "server"); + const nestedServer = path.join(entries.directory, "server", "feature", "client"); + const nestedShared = path.join(entries.directory, "shared", "feature"); + await Promise.all([ + mkdir(nestedClient, { recursive: true }), + mkdir(nestedServer, { recursive: true }), + mkdir(nestedShared, { recursive: true }), + ]); + await Promise.all([ + writeFile(path.join(nestedShared, "value.ts"), `export const value = "shared nested";`), + writeFile( + path.join(nestedClient, "value.ts"), + `export { value } from "../../../shared/feature/value";`, + ), + writeFile( + path.join(nestedServer, "value.ts"), + `export { value } from "../../../shared/feature/value";`, + ), + writeFile( + entries.client, + `import { value } from "./client/feature/server/value"; +export default function contribute() { void value; return () => undefined; }`, + ), + writeFile( + entries.server, + `import { value } from "./server/feature/client/value"; +export default function contribute() { void value; return () => undefined; }`, + ), + ]); + + const { clientBundle, serverBundle } = await compilePlugin(entries); + + expect(clientBundle).toContain("shared nested"); + expect(serverBundle).toContain("shared nested"); + }); + + it("does not classify dependency directories as plugin runtime boundaries", async () => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules", "fixture-dependency"); + await mkdir(path.join(dependency, "client"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "fixture-dependency", main: "index.js" }), + ), + writeFile( + path.join(dependency, "index.js"), + `const { label } = require("./client/label"); +module.exports = { label };`, + ), + writeFile( + path.join(dependency, "client", "label.js"), + `module.exports = { label: "dependency client directory" };`, + ), + writeFile( + entries.server, + `import { label } from "fixture-dependency"; +export default function contribute() { + void label; + return () => undefined; +}`, + ), + ]); + + const { serverBundle } = await compilePlugin(entries); + + expect(serverBundle).toContain("dependency client directory"); + }); + + it("rejects dependency-relative imports that escape into the opposite runtime", async () => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules", "fixture-dependency"); + await mkdir(dependency, { recursive: true }); + await Promise.all([ + writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "fixture-dependency", main: "index.js" }), + ), + writeFile( + path.join(dependency, "index.js"), + `module.exports = require("../../server/handler");`, + ), + writeFile( + entries.client, + `import handler from "fixture-dependency"; +export default function contribute() { void handler; return () => undefined; }`, + ), + ]); + + await expect(compilePlugin(entries)).rejects.toThrow( + "server-only module cannot be imported into the plugin client bundle", + ); + }); + + it("classifies symlinks by their canonical target", async () => { + const entries = await createSplitPlugin(); + const link = path.join(entries.directory, "client", "handler.ts"); + await symlink(path.join(entries.directory, "server", "handler.ts"), link); + await writeFile( + entries.client, + `import { handler } from "./client/handler"; +export default function contribute() { void handler; return () => undefined; }`, + ); + + await expect(compilePlugin(entries)).rejects.toThrow( + "server-only module cannot be imported into the plugin client bundle", + ); + }); + + it("classifies bare package imports by their canonical target", async () => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules", "fixture-dependency"); + await mkdir(dependency, { recursive: true }); + await Promise.all([ + writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "fixture-dependency", main: "index.js" }), + ), + symlink( + path.join(entries.directory, "server", "handler.ts"), + path.join(dependency, "index.js"), + ), + writeFile( + entries.client, + `import { handler } from "fixture-dependency"; +export default function contribute() { void handler; return () => undefined; }`, + ), + ]); + + await expect(compilePlugin(entries)).rejects.toThrow( + "server-only module cannot be imported into the plugin client bundle", + ); + }); + + it("allows linked dependencies to resolve within their own package root", async () => { + const entries = await createSplitPlugin(); + const linkedPackage = await mkdtemp(path.join(tmpdir(), "paseo-plugin-linked-dependency-")); + temporaryDirectories.push(linkedPackage); + await mkdir(path.join(entries.directory, "node_modules")); + await Promise.all([ + writeFile( + path.join(linkedPackage, "package.json"), + JSON.stringify({ name: "fixture-dependency", main: "index.js" }), + ), + writeFile(path.join(linkedPackage, "index.js"), `module.exports = require("./value");`), + writeFile(path.join(linkedPackage, "value.js"), `module.exports = "linked dependency";`), + symlink( + linkedPackage, + path.join(entries.directory, "node_modules", "fixture-dependency"), + process.platform === "win32" ? "junction" : "dir", + ), + writeFile( + entries.server, + `import value from "fixture-dependency"; +export default function contribute() { void value; return () => undefined; }`, + ), + ]); + + const { serverBundle } = await compilePlugin(entries); + + 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"; +export default function contribute() { void secret; return () => undefined; }`, + ), + ]); + + await expect(compilePlugin(entries)).rejects.toThrow( + `Plugin modules belong in client/, server/, or shared/: ${secret}`, + ); + }); + + it("does not let remembered linked roots hide plugin-local runtime boundaries", async () => { + const entries = await createSplitPlugin(); + const linkedPackage = await mkdtemp(path.join(tmpdir(), "paseo-plugin-linked-boundary-")); + temporaryDirectories.push(linkedPackage); + await mkdir(path.join(entries.directory, "node_modules")); + await Promise.all([ + writeFile( + path.join(linkedPackage, "package.json"), + JSON.stringify({ name: "fixture-dependency", main: "index.js" }), + ), + writeFile(path.join(linkedPackage, "index.js"), `module.exports = "linked dependency";`), + writeFile(path.join(linkedPackage, "secret.ts"), `export const secret = "linked secret";`), + symlink( + linkedPackage, + path.join(entries.directory, "node_modules", "fixture-dependency"), + process.platform === "win32" ? "junction" : "dir", + ), + symlink( + path.join(linkedPackage, "secret.ts"), + path.join(entries.directory, "server", "linked-secret.ts"), + ), + writeFile( + entries.client, + `import value from "fixture-dependency"; +import { secret } from "./server/linked-secret"; +export default function contribute() { void value; void secret; return () => undefined; }`, + ), + ]); + + await expect(compilePlugin(entries)).rejects.toThrow( + "server-only module cannot be imported into the plugin client bundle", + ); + }); + + it("builds a single runtime when the other entry is absent", async () => { + const entries = await createSplitPlugin(); + const { clientBundle, serverBundle } = await compilePlugin({ + client: entries.client, + server: null, + }); + expect(clientBundle).toContain("Client contribution"); + expect(serverBundle).toBeNull(); }); }); diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 490c957924..981ccc1229 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -1,9 +1,7 @@ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import { parse } from "@babel/parser"; -import type { Plugin } from "esbuild"; +import type { OnResolveResult, Plugin } from "esbuild"; import { PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, PLUGIN_SDK_SPECIFIERS, @@ -71,196 +69,140 @@ function loadEsbuild(): typeof import("esbuild") { type PluginBuildTarget = "client" | "server"; -interface SourceRange { - start: number; - end: number; +type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid"; + +function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null { + const relative = path.relative(pluginDirectory, filePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid"; + const segments = relative.split(path.sep); + if (segments.includes("node_modules")) return null; + if (segments[0] === "client") return "client"; + if (segments[0] === "server") return "server"; + if (segments[0] === "shared") return "shared"; + if (/^index\.client\.tsx?$/.test(relative)) return "client"; + if (/^index\.server\.tsx?$/.test(relative)) return "server"; + return "invalid"; } -const REGISTRATIONS_REMOVED_BY_TARGET: Record> = { - client: new Set(["handle"]), - server: new Set([ - "addSurface", - "addSidebarItem", - "addWorkspacePanel", - "addCommandCenterItem", - "addClientSlashCommand", - "addClientSide", - "addAttachmentSource", - "addTheme", - "addTimelineTransformer", - "addTimelineRenderer", - ]), -}; - -function registrationName(statement: unknown, contextName: string): string | null { - if (!statement || typeof statement !== "object") return null; - const expression = Reflect.get(statement, "expression"); - if ( - !expression || - typeof expression !== "object" || - Reflect.get(expression, "type") !== "CallExpression" - ) { - return null; - } - const callee = Reflect.get(expression, "callee"); - if (!callee || typeof callee !== "object" || Reflect.get(callee, "type") !== "MemberExpression") { - return null; - } - const object = Reflect.get(callee, "object"); - const property = Reflect.get(callee, "property"); - if ( - !object || - typeof object !== "object" || - Reflect.get(object, "type") !== "Identifier" || - Reflect.get(object, "name") !== contextName || - !property || - typeof property !== "object" || - Reflect.get(property, "type") !== "Identifier" - ) { - return null; - } - return String(Reflect.get(property, "name")); +function containsPath(directory: string, filePath: string): boolean { + const relative = path.relative(directory, filePath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } -function defaultPluginFunction(programBody: unknown[]): { body: object; contextName: string } { - const defaultExports = programBody.filter( - (statement) => - statement !== null && - typeof statement === "object" && - Reflect.get(statement, "type") === "ExportDefaultDeclaration", - ); - if (defaultExports.length !== 1) { - throw new Error("Plugin entry point must have exactly one default export function"); - } - const declaration = Reflect.get(defaultExports[0] as object, "declaration"); - const declarationType = - declaration !== null && typeof declaration === "object" - ? Reflect.get(declaration, "type") - : null; - if ( - declarationType !== "FunctionDeclaration" && - declarationType !== "FunctionExpression" && - declarationType !== "ArrowFunctionExpression" - ) { - throw new Error("Plugin default export must be a function receiving its context"); - } - const parameters = Reflect.get(declaration, "params"); - const parameter = Array.isArray(parameters) && parameters.length === 1 ? parameters[0] : null; - if ( - parameter === null || - typeof parameter !== "object" || - Reflect.get(parameter, "type") !== "Identifier" - ) { - throw new Error("Plugin default export must receive one named context parameter"); - } - const body = Reflect.get(declaration, "body"); - if (body === null || typeof body !== "object" || Reflect.get(body, "type") !== "BlockStatement") { - throw new Error("Plugin default export must have a block body"); - } - return { body, contextName: String(Reflect.get(parameter, "name")) }; +function dependencyName(specifier: string): string { + const segments = specifier.split("/"); + return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]; } -function collectRemovedRegistrationRanges( - node: unknown, - contextName: string, - removedNames: ReadonlySet, - ranges: SourceRange[], -): void { - if (node === null || typeof node !== "object") return; - if (Reflect.get(node, "type") === "ExpressionStatement") { - const name = registrationName(node, contextName); - if (name && removedNames.has(name)) { - const start = Reflect.get(node, "start"); - const end = Reflect.get(node, "end"); - if (typeof start !== "number" || typeof end !== "number") { - throw new Error( - `Could not locate plugin context ${name} registration in plugin entry point`, - ); +function findDependencyRoot( + resolvedPath: string, + specifier: string, + pluginDirectory: string, +): string | null { + const expectedName = dependencyName(specifier); + 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 (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) { + return null; + } + return directory; } - ranges.push({ start, end }); - return; - } - } - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const child of value) - collectRemovedRegistrationRanges(child, contextName, removedNames, ranges); - } else if (value !== null && typeof value === "object") { - collectRemovedRegistrationRanges(value, contextName, removedNames, ranges); } + const parent = path.dirname(directory); + if (parent === directory) return null; + directory = parent; } } -function moduleTarget(specifier: string): PluginBuildTarget | null { - if (/\.client(?:\.[cm]?[jt]sx?)?$/.test(specifier)) return "client"; - if (/\.server(?:\.[cm]?[jt]sx?)?$/.test(specifier)) return "server"; - return null; -} - -function collectOppositeTargetImportRanges( - programBody: unknown[], +function moduleBoundaryError( + moduleLocation: PluginModuleLocation | null, target: PluginBuildTarget, - ranges: SourceRange[], -): void { - for (const statement of programBody) { - if ( - statement === null || - typeof statement !== "object" || - Reflect.get(statement, "type") !== "ImportDeclaration" - ) { - continue; - } - const source = Reflect.get(statement, "source"); - const specifier = - source !== null && typeof source === "object" ? Reflect.get(source, "value") : null; - if (typeof specifier !== "string") continue; - const importedTarget = moduleTarget(specifier); - if (importedTarget === null || importedTarget === target) continue; - const start = Reflect.get(statement, "start"); - const end = Reflect.get(statement, "end"); - if (typeof start !== "number" || typeof end !== "number") { - throw new Error(`Could not locate ${importedTarget}-only import in plugin entry point`); - } - ranges.push({ start, end }); + filePath: string, +): OnResolveResult | null { + if (moduleLocation === "invalid") { + return { + errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }], + }; } + if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) { + return null; + } + return { + errors: [ + { + text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`, + }, + ], + }; } -function filterEntrypoint(source: string, target: PluginBuildTarget): string { - const ast = parse(source, { - sourceType: "module", - plugins: ["typescript", "jsx"], - }); - const pluginFunction = defaultPluginFunction(ast.program.body); - const ranges: SourceRange[] = []; - collectRemovedRegistrationRanges( - pluginFunction.body, - pluginFunction.contextName, - REGISTRATIONS_REMOVED_BY_TARGET[target], - ranges, - ); - collectOppositeTargetImportRanges(ast.program.body, target, ranges); - - let output = source; - for (const range of ranges.toSorted((left, right) => right.start - left.start)) { - output = `${output.slice(0, range.start)}${output.slice(range.end)}`; - } - return output; +function lexicalBoundaryError( + specifier: string, + resolveDirectory: string, + pluginDirectory: string, + target: PluginBuildTarget, +): 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); } -function createRuntimeBoundaryPlugin(target: PluginBuildTarget): Plugin { +function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin { + const boundaryResolution = {}; + const linkedDependencyRoots = new Set(); return { name: `paseo-plugin-${target}-runtime-boundary`, setup(buildContext) { - buildContext.onResolve({ filter: /\.(?:client|server)(?:\.[cm]?[jt]sx?)?$/ }, (args) => { - const importedTarget = moduleTarget(args.path); - if (importedTarget === null || importedTarget === target) return null; - return { - errors: [ - { - text: `${importedTarget}-only module cannot be imported into the plugin ${target} bundle: ${args.path}`, - }, - ], - }; + 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, + ); + if (lexicalError) return lexicalError; + const resolution = await buildContext.resolve(args.path, { + importer: args.importer, + namespace: args.namespace, + resolveDir: args.resolveDir, + kind: args.kind, + pluginData: boundaryResolution, + with: args.with, + }); + if ( + resolution.errors.length > 0 || + resolution.external || + resolution.namespace !== "file" + ) { + 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); }); }, }; @@ -284,19 +226,16 @@ function exactSpecifierFilter(specifiers: readonly string[]): RegExp { return new RegExp(`^(${alternatives.join("|")})$`); } -function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin { - const filter = - target === "server" - ? exactSpecifierFilter([ - "@tanstack/react-query", - "react", - "react/jsx-runtime", - "react-native", - ...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, - ]) - : /^node:/; +function createUnusedPlatformModulePlugin(): Plugin { + const filter = exactSpecifierFilter([ + "@tanstack/react-query", + "react", + "react/jsx-runtime", + "react-native", + ...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, + ]); return { - name: `paseo-plugin-${target}-unused-platform-modules`, + name: "paseo-plugin-server-unused-platform-modules", setup(buildContext) { buildContext.onResolve({ filter }, (args) => ({ path: args.path, @@ -311,17 +250,26 @@ function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin { }; } +function createClientNodeImportPlugin(): Plugin { + return { + name: "paseo-plugin-client-node-imports", + setup(buildContext) { + buildContext.onResolve({ filter: /^node:/ }, (args) => ({ + errors: [ + { + text: `Node module cannot be imported into the plugin client bundle: ${args.path} imported by ${args.importer}`, + }, + ], + })); + }, + }; +} + async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { const { build } = loadEsbuild(); - const source = await readFile(entryPath, "utf8"); - const filteredSource = filterEntrypoint(source, target); + const pluginDirectory = path.dirname(entryPath); const result = await build({ - stdin: { - contents: filteredSource, - loader: "tsx", - resolveDir: path.dirname(entryPath), - sourcefile: entryPath, - }, + entryPoints: [entryPath], bundle: true, format: "cjs", jsx: target === "client" ? "automatic" : undefined, @@ -341,7 +289,12 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom "zod", ] : [...PLUGIN_SDK_SPECIFIERS, "zod"], - plugins: [createRuntimeBoundaryPlugin(target), createUnusedPlatformModulePlugin(target)], + plugins: [ + createRuntimeBoundaryPlugin(target, pluginDirectory), + ...(target === "client" + ? [createClientNodeImportPlugin()] + : [createUnusedPlatformModulePlugin()]), + ], logLevel: "silent", treeShaking: true, write: false, @@ -351,13 +304,16 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom return wrapCommonJsBundle(makeHermesInteropEager(output)); } -export async function compilePlugin(entryPath: string): Promise<{ - clientBundle: string; - serverBundle: string; +export async function compilePlugin(entryPaths: { + client: string | null; + server: string | null; +}): Promise<{ + clientBundle: string | null; + serverBundle: string | null; }> { const [clientBundle, serverBundle] = await Promise.all([ - compileTarget(entryPath, "client"), - compileTarget(entryPath, "server"), + entryPaths.client ? compileTarget(entryPaths.client, "client") : null, + entryPaths.server ? compileTarget(entryPaths.server, "server") : null, ]); return { clientBundle, serverBundle }; } diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index f9222b72ab..33d790a59b 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -16,7 +16,7 @@ async function createPlugin(id: string, source: string): Promise { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-")); roots.push(directory); await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id })); - await writeFile(path.join(directory, "index.tsx"), source); + await writeFile(path.join(directory, "index.server.ts"), source); return directory; } @@ -244,7 +244,7 @@ describe("PluginService", () => { "choose another ID with --id", ); - await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!"); + await writeFile(path.join(directory, "index.server.ts"), "export default broken syntax !!!"); await expect(service.reloadPlugin("work-plugin")).rejects.toThrow(); expect(service.catalog()).toEqual([]); expect(service.listPlugins()).toEqual([ @@ -252,7 +252,7 @@ describe("PluginService", () => { ]); await writeFile( - path.join(directory, "index.tsx"), + path.join(directory, "index.server.ts"), `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, ); await expect(service.reloadPlugin("work-plugin")).resolves.toMatchObject({ status: "running" }); @@ -271,7 +271,7 @@ describe("PluginService", () => { JSON.stringify({ id: "local-monorepo" }), ); await writeFile( - path.join(pluginDirectory, "index.ts"), + path.join(pluginDirectory, "index.server.ts"), "export default function contribute(plugin: unknown) { void plugin; return () => undefined; }", ); const service = createService(home); @@ -296,7 +296,7 @@ describe("PluginService", () => { JSON.stringify({ id: "git-update" }), ); await writeFile( - path.join(repository, "index.ts"), + path.join(repository, "index.server.ts"), "export default function contribute(plugin: unknown) { void plugin; return () => undefined; }", ); await runGitCommand(["add", "-A"], { cwd: repository }); @@ -323,7 +323,7 @@ describe("PluginService", () => { }), ); await writeFile( - path.join(repository, "index.ts"), + path.join(repository, "index.server.ts"), 'export default function contribute(plugin: unknown) { void plugin; throw new Error("broken update"); }', ); await runGitCommand(["add", "-A"], { cwd: repository }); @@ -367,7 +367,7 @@ describe("PluginService", () => { ], }), ); - await writeFile(path.join(repository, "index.ts"), "export default () => () => {};\n"); + await writeFile(path.join(repository, "index.server.ts"), "export default () => () => {};\n"); await runGitCommand(["add", "-A"], { cwd: repository }); await runGitCommand(["commit", "-m", "initial"], { cwd: repository }); @@ -442,7 +442,7 @@ describe("PluginService", () => { path.join(repository, "paseo-plugin.json"), JSON.stringify({ id: "failed-update" }), ); - await writeFile(path.join(repository, "index.ts"), "export default () => () => {};\n"); + await writeFile(path.join(repository, "index.server.ts"), "export default () => () => {};\n"); await runGitCommand(["add", "-A"], { cwd: repository }); await runGitCommand(["commit", "-m", "initial"], { cwd: repository }); @@ -488,7 +488,7 @@ describe("PluginService", () => { ]); await writeFile( - path.join(repository, "index.ts"), + path.join(repository, "index.server.ts"), "export default () => () => { new Date(); };\n", ); await runGitCommand(["add", "-A"], { cwd: repository }); @@ -683,7 +683,7 @@ export default function contribute(plugin: unknown) { const invalid = await createPlugin("valid-before-corruption", "export default () => () => {};"); await writeFile(path.join(invalid, "paseo-plugin.json"), JSON.stringify({})); const missingEntry = await createPlugin("missing-entry", "export default () => () => {};"); - await rm(path.join(missingEntry, "index.tsx")); + await rm(path.join(missingEntry, "index.server.ts")); const startupFailure = await createPlugin( "startup-failure", `export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`, @@ -693,7 +693,7 @@ export default function contribute(plugin: unknown) { await expect(service.installDirectory({ path: invalid })).rejects.toThrow(); await expect(service.installDirectory({ path: missingEntry })).rejects.toThrow( - "Plugin entry point is missing", + "Plugin entry points are missing", ); await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow( "startup exploded", @@ -702,7 +702,7 @@ export default function contribute(plugin: unknown) { expect.objectContaining({ id: "missing-entry", status: "failed", - error: expect.stringContaining("Plugin entry point is missing"), + error: expect.stringContaining("Plugin entry points are missing"), }), expect.objectContaining({ id: "startup-failure", diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 411e93e195..d315a146d3 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -22,7 +22,7 @@ async function createRepository(): Promise { path.join(repository, "paseo-plugin.json"), JSON.stringify({ id: "managed-example" }), ); - await writeFile(path.join(repository, "index.ts"), "export default () => () => {};\n"); + await writeFile(path.join(repository, "index.server.ts"), "export default () => () => {};\n"); await commitAll(repository, "initial"); return repository; } @@ -60,7 +60,7 @@ describe("managed Git plugin sources", () => { const initial = candidate.record.commit; await writeFile( - path.join(repository, "index.ts"), + path.join(repository, "index.server.ts"), "export default () => () => { new Date(); };\n", ); const latest = await commitAll(repository, "update"); @@ -77,7 +77,9 @@ describe("managed Git plugin sources", () => { if (!prepared.candidate) throw new Error("Expected an update candidate"); const updated = await sources.place("managed-example", prepared.candidate); sources.commit("managed-example", updated.record); - expect(await readFile(path.join(updated.directory, "index.ts"), "utf8")).toContain("new Date"); + expect(await readFile(path.join(updated.directory, "index.server.ts"), "utf8")).toContain( + "new Date", + ); expect(new ManagedPluginSources(home).get("managed-example")?.commit).toBe(latest); await runGitCommand(["tag", "v1", initial], { cwd: repository }); diff --git a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index 6a3acdb9c1..ed28f04196 100644 --- a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts @@ -21,8 +21,8 @@ test("plugin handlers create workspaces and agents through their Paseo API", asy JSON.stringify({ id: "paseo-api" }), ); await writeFile( - path.join(pluginDirectory, "index.tsx"), - `import { defineRpc, type PluginContext } from "@getpaseo/plugin"; + path.join(pluginDirectory, "index.server.ts"), + `import { defineRpc, type PluginServerContext } from "@getpaseo/plugin"; import { z } from "zod"; const create = defineRpc({ @@ -43,8 +43,8 @@ const append = defineRpc({ output: z.object({ seq: z.number(), epoch: z.string() }), }); -export default function contribute(plugin: PluginContext) { - plugin.handle(create, async ({ path }, { paseo }) => { +export default function contribute(server: PluginServerContext) { + server.handle(create, async ({ path }, { paseo }) => { const workspace = await paseo.workspaces.create({ source: { kind: "directory", path }, title: "Plugin workspace", @@ -55,11 +55,11 @@ export default function contribute(plugin: PluginContext) { }); return { workspaceId: workspace.id, agentId: agent.id }; }); - plugin.handle(list, async (_input, { paseo }) => { + server.handle(list, async (_input, { paseo }) => { const result = await paseo.agents.list({ page: { limit: 100 } }); return { agentIds: result.entries.map((entry) => entry.agent.id) }; }); - plugin.handle(append, ({ agentId, status }, { paseo }) => + server.handle(append, ({ agentId, status }, { paseo }) => paseo.agents.ref(agentId).timeline.append({ type: "plugin", id: "review-1", @@ -144,9 +144,9 @@ test("daemon config reload enables and disables configured plugins without resta JSON.stringify({ id: "reloadable-plugin" }), ); await writeFile( - path.join(pluginDirectory, "index.tsx"), - `export default function contribute(plugin: unknown) { - void plugin; + path.join(pluginDirectory, "index.server.ts"), + `export default function contribute(server: unknown) { + void server; return () => undefined; }`, ); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index f50d24530f..1b4082df4d 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -1,15 +1,15 @@ import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; import { createRequire } from "node:module"; -import { - defineAttachmentSource, - defineRpc, - type PluginHandlerContext, - type PluginRpcContract, -} from "@getpaseo/plugin/server"; +import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@getpaseo/plugin"; +import type { PluginHandlerContext } from "@getpaseo/plugin/server"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { createPluginDaemonTransportFactory } from "./daemon-transport.js"; -import { isPluginClientOnlySdkSpecifier, isPluginSdkSpecifier } from "./plugin-sdk-specifiers.js"; +import { + isPluginClientOnlySdkSpecifier, + isPluginSdkSpecifier, + isPluginServerTypesSdkSpecifier, +} from "./plugin-sdk-specifiers.js"; import { createPluginClientId } from "./plugin-session-identity.js"; type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise; @@ -75,6 +75,7 @@ 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; return nodeRequire(name); } diff --git a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts index 3b7384e0e7..f4ae72e55b 100644 --- a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts +++ b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts @@ -10,11 +10,15 @@ export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [ "@paseo/plugin/react-native", ] as const; +export const PLUGIN_SERVER_TYPES_SDK_SPECIFIERS = [ + "@getpaseo/plugin/server", + "@paseo/plugin/server", +] as const; + export const PLUGIN_SDK_SPECIFIERS = [ "@getpaseo/plugin", - "@getpaseo/plugin/server", "@paseo/plugin", - "@paseo/plugin/server", + ...PLUGIN_SERVER_TYPES_SDK_SPECIFIERS, ...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, ] as const; @@ -25,3 +29,7 @@ export function isPluginSdkSpecifier(name: string): boolean { 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); +} diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 297e9bcd2b..9b285687d7 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; import path from "node:path"; @@ -14,7 +14,7 @@ async function createPlugin(id: string, source: string): Promise { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); temporaryDirectories.push(directory); await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id }), "utf8"); - await writeFile(path.join(directory, "index.tsx"), source, "utf8"); + await writeFile(path.join(directory, "index.server.ts"), source, "utf8"); return directory; } @@ -494,20 +494,57 @@ export default function contribute(plugin: unknown) { await runtime.stopAll(); }); - it("loads one index.tsx, exposes its client bundle, and invokes its server RPC", async () => { - const directory = await createPlugin( - "hello", - `import React from "react"; -import { platform } from "node:os"; -import { Text } from "react-native"; -import { z } from "zod"; -import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin"; + it("rejects an index.ts plugin with the migration path", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); + temporaryDirectories.push(directory); + await Promise.all([ + writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "legacy" })), + writeFile(path.join(directory, "index.ts"), "export default function contribute() {}"), + ]); + const runtime = createTestRuntime(); -const greetRpc = defineRpc({ - name: "greet", - input: z.object({ name: z.string() }), - output: z.object({ message: z.string(), platform: z.string() }), -}); + await expect(runtime.startPlugin("legacy", directory)).rejects.toThrow( + "Plugin entry split is required", + ); + await expect(runtime.startPlugin("legacy", directory)).rejects.toThrow( + "https://paseo.sh/docs/plugins/migration", + ); + }); + + it("loads a client-only plugin without spawning a subprocess", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); + temporaryDirectories.push(directory); + await Promise.all([ + writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "theme" })), + writeFile( + path.join(directory, "index.client.tsx"), + `export default function contribute(client: any) { + client.addTheme({ id: "theme", name: "Theme", appearance: "dark", colors: {} }); + return () => undefined; +}`, + ), + ]); + const spawnChild = vi.fn(); + const runtime = createTestRuntime({ spawnChild }); + + await runtime.startPlugin("theme", directory); + + expect(spawnChild).not.toHaveBeenCalled(); + expect(runtime.catalog()[0]?.clientBundle).toContain("addTheme"); + await runtime.stopAll(); + }); + + it("loads separate entries, exposes the client bundle, and invokes the server RPC", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); + temporaryDirectories.push(directory); + await mkdir(path.join(directory, "shared")); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "hello" })); + await writeFile( + path.join(directory, "index.client.tsx"), + `import React from "react"; +import { Text } from "react-native"; +import { defineAttachmentSource } from "@getpaseo/plugin"; +import { greetRpc } from "./shared/greet"; const attachments = defineAttachmentSource({ id: "issues", @@ -526,16 +563,34 @@ function ReviewPanel() { return Workspace review panel; } -export default function contribute(plugin: any) { - plugin.handle(greetRpc, async (input: { name: string }) => ({ +export default function contribute(client: any) { + client.addSurface("main", HelloSurface); + client.addSidebarItem({ id: "hello", title: "Hello", icon: "Sparkles", surface: "main" }); + client.addWorkspacePanel({ id: "review", title: "Review", icon: "Scan", context: "workspace", Component: ReviewPanel }); + client.addCommandCenterItem({ id: "open-review", title: "Open review", icon: "Scan", context: "workspace", onSelect() {} }); + client.addAttachmentSource(attachments); + return () => undefined; +}`, + ); + await writeFile( + path.join(directory, "shared", "greet.ts"), + `import { z } from "zod"; +import { defineRpc } from "@getpaseo/plugin"; +export const greetRpc = defineRpc({ + name: "greet", + input: z.object({ name: z.string() }), + output: z.object({ message: z.string(), platform: z.string() }), +});`, + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import { platform } from "node:os"; +import { greetRpc } from "./shared/greet"; +export default function contribute(server: any) { + server.handle(greetRpc, async (input: { name: string }) => ({ message: "Hello, " + input.name, platform: platform(), })); - plugin.addSurface("main", HelloSurface); - plugin.addSidebarItem({ id: "hello", title: "Hello", icon: "Sparkles", surface: "main" }); - plugin.addWorkspacePanel({ id: "review", title: "Review", icon: "Scan", context: "workspace", Component: ReviewPanel }); - plugin.addCommandCenterItem({ id: "open-review", title: "Open review", icon: "Scan", context: "workspace", onSelect() {} }); - plugin.addAttachmentSource(attachments); return () => undefined; }`, ); @@ -566,7 +621,7 @@ export default function contribute(plugin: any) { const directory = await createPlugin( "legacy-sdk", `import { z } from "zod"; -import { defineRpc } from "@paseo/plugin/server"; +import { defineRpc } from "@paseo/plugin"; const pingRpc = defineRpc({ name: "ping", @@ -591,6 +646,11 @@ export default function contribute(plugin: any) { it("keeps client and server modules in their target runtime", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); temporaryDirectories.push(directory); + await Promise.all([ + mkdir(path.join(directory, "client")), + mkdir(path.join(directory, "server")), + mkdir(path.join(directory, "shared")), + ]); await Promise.all([ writeFile( path.join(directory, "paseo-plugin.json"), @@ -598,21 +658,28 @@ export default function contribute(plugin: any) { "utf8", ), writeFile( - path.join(directory, "index.ts"), - `import type { PluginContext } from "@getpaseo/plugin"; -import { Surface } from "./surface.client"; -import { inspectRpc } from "./inspect.shared"; -import { inspectHost } from "./inspect.server"; - -export default function contribute(plugin: PluginContext) { - plugin.handle(inspectRpc, inspectHost); - plugin.addSurface("main", Surface); + path.join(directory, "index.client.tsx"), + `import type { PluginClientContext } from "@getpaseo/plugin"; +import { Surface } from "./client/surface"; +export default function contribute(client: PluginClientContext) { + client.addSurface("main", Surface); return () => undefined; }`, "utf8", ), writeFile( - path.join(directory, "surface.client.tsx"), + path.join(directory, "index.server.ts"), + `import type { PluginServerContext } from "@getpaseo/plugin"; +import { inspectRpc } from "./shared/inspect"; +import { inspectHost } from "./server/inspect"; +export default function contribute(server: PluginServerContext) { + server.handle(inspectRpc, inspectHost); + return () => undefined; +}`, + "utf8", + ), + writeFile( + path.join(directory, "client", "surface.tsx"), `import React from "react"; import { StyleSheet, Text } from "react-native"; @@ -624,8 +691,8 @@ export function Surface() { "utf8", ), writeFile( - path.join(directory, "inspect.shared.ts"), - `import { defineRpc } from "@getpaseo/plugin/server"; + path.join(directory, "shared", "inspect.ts"), + `import { defineRpc } from "@getpaseo/plugin"; import { z } from "zod"; export const inspectRpc = defineRpc({ @@ -636,10 +703,10 @@ export const inspectRpc = defineRpc({ "utf8", ), writeFile( - path.join(directory, "inspect.server.ts"), + path.join(directory, "server", "inspect.ts"), `import { platform } from "node:os"; import type { z } from "zod"; -import { inspectRpc } from "./inspect.shared"; +import { inspectRpc } from "../shared/inspect"; export function inspectHost(_input: z.input) { return { platform: platform() }; @@ -663,6 +730,10 @@ export function inspectHost(_input: z.input) { it("rejects server imports from client-only modules", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); temporaryDirectories.push(directory); + await Promise.all([ + mkdir(path.join(directory, "client")), + mkdir(path.join(directory, "server")), + ]); await Promise.all([ writeFile( path.join(directory, "paseo-plugin.json"), @@ -670,24 +741,24 @@ export function inspectHost(_input: z.input) { "utf8", ), writeFile( - path.join(directory, "index.ts"), - `import type { PluginContext } from "@getpaseo/plugin"; -import { Surface } from "./surface.client"; + path.join(directory, "index.client.tsx"), + `import type { PluginClientContext } from "@getpaseo/plugin"; +import { Surface } from "./client/surface"; -export default function contribute(plugin: PluginContext) { - plugin.addSurface("main", Surface); +export default function contribute(client: PluginClientContext) { + client.addSurface("main", Surface); return () => undefined; }`, "utf8", ), writeFile( - path.join(directory, "surface.client.tsx"), - `import { readSecret } from "./secret.server"; + path.join(directory, "client", "surface.tsx"), + `import { readSecret } from "../server/secret"; export function Surface() { return readSecret(); }`, "utf8", ), writeFile( - path.join(directory, "secret.server.ts"), + path.join(directory, "server", "secret.ts"), `export function readSecret() { return null; }`, "utf8", ), @@ -703,6 +774,11 @@ export function Surface() { return readSecret(); }`, it("rejects client imports from server-only modules", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); temporaryDirectories.push(directory); + await Promise.all([ + mkdir(path.join(directory, "client")), + mkdir(path.join(directory, "server")), + mkdir(path.join(directory, "shared")), + ]); await Promise.all([ writeFile( path.join(directory, "paseo-plugin.json"), @@ -710,19 +786,19 @@ export function Surface() { return readSecret(); }`, "utf8", ), writeFile( - path.join(directory, "index.ts"), - `import type { PluginContext } from "@getpaseo/plugin"; -import { inspect } from "./inspect.server"; -import { inspectRpc } from "./inspect.shared"; + path.join(directory, "index.server.ts"), + `import type { PluginServerContext } from "@getpaseo/plugin"; +import { inspect } from "./server/inspect"; +import { inspectRpc } from "./shared/inspect"; -export default function contribute(plugin: PluginContext) { - plugin.handle(inspectRpc, inspect); +export default function contribute(server: PluginServerContext) { + server.handle(inspectRpc, inspect); return () => undefined; }`, "utf8", ), writeFile( - path.join(directory, "inspect.shared.ts"), + path.join(directory, "shared", "inspect.ts"), `import { defineRpc } from "@getpaseo/plugin"; import { z } from "zod"; export const inspectRpc = defineRpc({ @@ -733,13 +809,13 @@ export const inspectRpc = defineRpc({ "utf8", ), writeFile( - path.join(directory, "inspect.server.ts"), - `import { Surface } from "./surface.client"; + path.join(directory, "server", "inspect.ts"), + `import { Surface } from "../client/surface"; export function inspect() { void Surface; return {}; }`, "utf8", ), writeFile( - path.join(directory, "surface.client.tsx"), + path.join(directory, "client", "surface.tsx"), `export function Surface() { return null; }`, "utf8", ), diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 31c9d87b95..63593d9199 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -10,9 +10,8 @@ import { readPluginManifest } from "./manifest.js"; import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; import { PluginSessionSocket } from "./session-socket.js"; -const ENTRY_FILENAME = "index.ts"; -// COMPAT(plugin-index-tsx): added in v0.4, remove after 2027-02-17 -const LEGACY_ENTRY_FILENAME = "index.tsx"; +const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; +const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const; const REQUEST_TIMEOUT_MS = 30_000; const MAX_LOG_ENTRIES = 500; const MAX_LOG_BYTES = 256 * 1024; @@ -44,11 +43,11 @@ interface LoadedPlugin { id: string; clientBundle: string; methods: ReadonlySet; - child: PluginChild; - outputCapture: PluginOutputCapture; + child: PluginChild | null; + outputCapture: PluginOutputCapture | null; pending: Map; - sessionSocket: PluginSessionSocket; - sessionClosed: Promise; + sessionSocket: PluginSessionSocket | null; + sessionClosed: Promise | null; } interface PluginRuntimeDependencies { @@ -187,13 +186,33 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise }); } -async function resolveEntryPath(directory: string): Promise { - for (const filename of [ENTRY_FILENAME, LEGACY_ENTRY_FILENAME]) { +async function findEntry(directory: string, filenames: readonly string[]): Promise { + for (const filename of filenames) { const filePath = path.join(directory, filename); const info = await stat(filePath).catch(() => null); if (info?.isFile()) return filePath; } - throw new Error(`Plugin entry point is missing: ${path.join(directory, ENTRY_FILENAME)}`); + return null; +} + +async function resolveEntryPaths(directory: string): Promise<{ + client: string | null; + server: string | null; +}> { + const [client, server] = await Promise.all([ + findEntry(directory, CLIENT_ENTRY_FILENAMES), + findEntry(directory, SERVER_ENTRY_FILENAMES), + ]); + if (client || server) return { client, server }; + const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]); + if (legacyEntry) { + throw new Error( + "Plugin entry split is required: migrate index.ts to index.client.tsx and/or index.server.ts. See https://paseo.sh/docs/plugins/migration", + ); + } + throw new Error( + `Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`, + ); } export class PluginRuntime { @@ -247,8 +266,8 @@ export class PluginRuntime { async validatePlugin(configuredPath: string): Promise { const directory = path.resolve(configuredPath); await readPluginManifest(directory); - const entryPath = await resolveEntryPath(directory); - await compilePlugin(entryPath); + const entryPaths = await resolveEntryPaths(directory); + await compilePlugin(entryPaths); } async stopPluginById(pluginId: string): Promise { @@ -286,6 +305,8 @@ export class PluginRuntime { if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); if (!loaded.methods.has(method)) throw new Error(`Plugin ${pluginId} does not contribute RPC ${method}`); + const child = loaded.child; + if (!child) throw new Error(`Plugin ${pluginId} has no server entry`); const requestId = randomUUID(); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -293,7 +314,7 @@ export class PluginRuntime { reject(new Error(`Plugin RPC timed out: ${pluginId}.${method}`)); }, REQUEST_TIMEOUT_MS); loaded.pending.set(requestId, { resolve, reject, timeout }); - void send(loaded.child, { type: "invoke", requestId, method, input }).catch((error) => { + void send(child, { type: "invoke", requestId, method, input }).catch((error) => { clearTimeout(timeout); loaded.pending.delete(requestId); reject(error); @@ -316,8 +337,21 @@ export class PluginRuntime { ): Promise { const directory = path.resolve(configuredPath); await readPluginManifest(directory); - const entryPath = await resolveEntryPath(directory); - const bundles = await compilePlugin(entryPath); + const entryPaths = await resolveEntryPaths(directory); + const bundles = await compilePlugin(entryPaths); + const serverBundle = bundles.serverBundle; + if (!serverBundle) { + return { + id: pluginId, + clientBundle: bundles.clientBundle ?? "", + methods: new Set(), + child: null, + outputCapture: null, + pending: new Map(), + sessionSocket: null, + sessionClosed: null, + }; + } const sessionHost = this.sessionHost; if (!sessionHost) throw new Error("Plugin Paseo session host is not attached"); const child = this.spawnChild(); @@ -375,7 +409,7 @@ export class PluginRuntime { type: "initialize", pluginId, appVersion: this.daemonVersion, - bundle: bundles.serverBundle, + bundle: serverBundle, }).catch(fail); }); } catch (error) { @@ -386,7 +420,7 @@ export class PluginRuntime { } loaded = { id: pluginId, - clientBundle: bundles.clientBundle, + clientBundle: bundles.clientBundle ?? "", methods: new Set(methods), child, outputCapture, @@ -409,7 +443,7 @@ export class PluginRuntime { } private async handleChildClose(loaded: LoadedPlugin): Promise { - loaded.sessionSocket.peerClosed(); + loaded.sessionSocket?.peerClosed(); const wasPublished = this.plugins.get(loaded.id) === loaded; if (wasPublished) { this.plugins.delete(loaded.id); @@ -421,23 +455,28 @@ export class PluginRuntime { private async stopPlugin(loaded: LoadedPlugin): Promise { this.appendLog(loaded.id, "stdout", "[paseo] Stopping plugin"); - if (loaded.child.killed) { - loaded.sessionSocket.peerClosed(); - await loaded.sessionClosed; + const { child, sessionSocket, sessionClosed } = loaded; + if (!child || !sessionSocket || !sessionClosed) { + this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped"); + return; + } + if (child.killed) { + sessionSocket.peerClosed(); + await sessionClosed; this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped"); return; } const closed = new Promise((resolve) => - loaded.child.on("close", () => { + child.on("close", () => { resolve(); }), ); - if (loaded.child.connected) { - await send(loaded.child, { type: "shutdown" }).catch(() => undefined); + if (child.connected) { + await send(child, { type: "shutdown" }).catch(() => undefined); } await closed; - loaded.sessionSocket.peerClosed(); - await loaded.sessionClosed; + sessionSocket.peerClosed(); + await sessionClosed; this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped"); } diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index c12c076e30..8469d300c9 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -893,11 +893,14 @@ describe("WorkspaceGitService checkout observation", () => { expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); }); - watcher.records - .find((record) => record.directory === GIT_DIR) - ?.callback(null, [ - { path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" }, - ]); + await vi.waitFor(() => { + expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1); + }); + const [repoWatcher] = getWatcherRecordsForDirectory(watcher, GIT_DIR); + if (!repoWatcher) throw new Error("Repository watcher was not registered"); + repoWatcher.callback(null, [ + { path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" }, + ]); releaseFetch.resolve(); await vi.waitFor(() => { expect(service.getMetrics().fetchInFlightCount).toBe(0); diff --git a/plugin-examples/catppuccin/README.md b/plugin-examples/catppuccin/README.md index a5ff1b6eb3..2e27e19821 100644 --- a/plugin-examples/catppuccin/README.md +++ b/plugin-examples/catppuccin/README.md @@ -3,7 +3,8 @@ This example adds **Catppuccin Mocha** to Settings → Appearance. Paseo ships Catppuccin as a syntax-highlight theme; this contributes it as an app theme. -A theme is data, so the whole plugin is one `addTheme` call in `index.ts` — no client file, no RPC. +A theme is client data, so the whole plugin is one `addTheme` call in `index.client.ts` and has no +server entry or subprocess. Register it in `$PASEO_HOME/config.json`: diff --git a/plugin-examples/catppuccin/index.ts b/plugin-examples/catppuccin/index.client.ts similarity index 69% rename from plugin-examples/catppuccin/index.ts rename to plugin-examples/catppuccin/index.client.ts index e7ee3f83ee..3dd78f6882 100644 --- a/plugin-examples/catppuccin/index.ts +++ b/plugin-examples/catppuccin/index.client.ts @@ -1,7 +1,7 @@ -import type { PluginContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin"; -export default function contribute(plugin: PluginContext) { - plugin.addTheme({ +export default function contribute(client: PluginClientContext) { + client.addTheme({ id: "mocha", name: "Catppuccin Mocha", appearance: "dark", diff --git a/plugin-examples/inline-thinking/README.md b/plugin-examples/inline-thinking/README.md index dfec7e8f83..99ddcd38bd 100644 --- a/plugin-examples/inline-thinking/README.md +++ b/plugin-examples/inline-thinking/README.md @@ -2,3 +2,4 @@ This plugin replaces reasoning rows from the first streaming delta and renders the paced text inline. Install the directory as `inline-thinking`, then run an agent that streams reasoning. +`index.client.tsx` wires the transformer and renderer to the component in `client/thinking.tsx`. diff --git a/plugin-examples/inline-thinking/thinking.client.tsx b/plugin-examples/inline-thinking/client/thinking.tsx similarity index 100% rename from plugin-examples/inline-thinking/thinking.client.tsx rename to plugin-examples/inline-thinking/client/thinking.tsx diff --git a/plugin-examples/inline-thinking/index.ts b/plugin-examples/inline-thinking/index.client.tsx similarity index 61% rename from plugin-examples/inline-thinking/index.ts rename to plugin-examples/inline-thinking/index.client.tsx index 8c13a0e304..f78b646761 100644 --- a/plugin-examples/inline-thinking/index.ts +++ b/plugin-examples/inline-thinking/index.client.tsx @@ -1,8 +1,8 @@ -import type { PluginContext } from "@getpaseo/plugin"; -import { InlineThinking, inlineThinkingSchema } from "./thinking.client"; +import type { PluginClientContext } from "@getpaseo/plugin"; +import { InlineThinking, inlineThinkingSchema } from "./client/thinking"; -export default function contribute(plugin: PluginContext) { - plugin.addTimelineTransformer({ +export default function contribute(client: PluginClientContext) { + client.addTimelineTransformer({ id: "inline-thinking", query: { itemType: "reasoning" }, transform: ({ item, phase }) => ({ @@ -16,7 +16,7 @@ export default function contribute(plugin: PluginContext) { ], }), }); - plugin.addTimelineRenderer({ + client.addTimelineRenderer({ kind: "inline-thinking", version: 1, schema: inlineThinkingSchema, diff --git a/plugin-examples/linear/README.md b/plugin-examples/linear/README.md index dad49a9133..2a227bf71c 100644 --- a/plugin-examples/linear/README.md +++ b/plugin-examples/linear/README.md @@ -27,7 +27,7 @@ Restart the development daemon after changing its environment or plugin configur The split entry point demonstrates the complete attachment-source pattern: -- define the validated search RPC and attachment metadata in `issues.shared.ts`; -- handle it in the daemon subprocess from `issues.server.ts`; -- wire both contributions from `index.ts`; +- define the validated search RPC and attachment metadata in `shared/issues.ts`; +- handle it in the daemon subprocess from `server/issues.ts` and `index.server.ts`; +- register the attachment source from `index.client.ts`; - keep credentials and vendor API calls out of the client bundle. diff --git a/plugin-examples/linear/index.client.ts b/plugin-examples/linear/index.client.ts new file mode 100644 index 0000000000..3985ff8c91 --- /dev/null +++ b/plugin-examples/linear/index.client.ts @@ -0,0 +1,7 @@ +import type { PluginClientContext } from "@getpaseo/plugin"; +import { issueAttachments } from "./shared/issues"; + +export default function contribute(client: PluginClientContext) { + client.addAttachmentSource(issueAttachments); + return () => {}; +} diff --git a/plugin-examples/linear/index.server.ts b/plugin-examples/linear/index.server.ts new file mode 100644 index 0000000000..841d26dd93 --- /dev/null +++ b/plugin-examples/linear/index.server.ts @@ -0,0 +1,8 @@ +import type { PluginServerContext } from "@getpaseo/plugin"; +import { searchIssues } from "./server/issues"; +import { searchIssuesRpc } from "./shared/issues"; + +export default function contribute(server: PluginServerContext) { + server.handle(searchIssuesRpc, searchIssues); + return () => {}; +} diff --git a/plugin-examples/linear/index.ts b/plugin-examples/linear/index.ts deleted file mode 100644 index 84b93af5b4..0000000000 --- a/plugin-examples/linear/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PluginContext } from "@getpaseo/plugin"; -import { searchIssues } from "./issues.server"; -import { issueAttachments, searchIssuesRpc } from "./issues.shared"; - -export default function contribute(plugin: PluginContext) { - plugin.handle(searchIssuesRpc, searchIssues); - plugin.addAttachmentSource(issueAttachments); - return () => {}; -} diff --git a/plugin-examples/linear/issues.server.ts b/plugin-examples/linear/issues.server.ts deleted file mode 100644 index d353d53adc..0000000000 --- a/plugin-examples/linear/issues.server.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { PluginAttachmentSearchPayload } from "@getpaseo/plugin/server"; -import type { output as ZodOutput } from "zod"; -import { searchIssuesRpc } from "./issues.shared"; -import { createLinearIssueSearch } from "./linear"; - -export async function searchIssues({ - query, -}: ZodOutput): Promise { - const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" }); - return linear.search(query); -} diff --git a/plugin-examples/linear/linear.test.ts b/plugin-examples/linear/linear.test.ts index 01fa002697..3f56bf6c74 100644 --- a/plugin-examples/linear/linear.test.ts +++ b/plugin-examples/linear/linear.test.ts @@ -1,7 +1,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import { describe, expect, it } from "vitest"; -import { createLinearIssueSearch } from "./linear"; +import { createLinearIssueSearch } from "./server/linear"; const issue = { id: "issue-uuid", diff --git a/plugin-examples/linear/server/issues.ts b/plugin-examples/linear/server/issues.ts new file mode 100644 index 0000000000..07d71735da --- /dev/null +++ b/plugin-examples/linear/server/issues.ts @@ -0,0 +1,10 @@ +import type { PluginAttachmentSearchPayload, RpcInput } from "@getpaseo/plugin"; +import { searchIssuesRpc } from "../shared/issues"; +import { createLinearIssueSearch } from "./linear"; + +export async function searchIssues({ + query, +}: RpcInput): Promise { + const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" }); + return linear.search(query); +} diff --git a/plugin-examples/linear/linear.ts b/plugin-examples/linear/server/linear.ts similarity index 100% rename from plugin-examples/linear/linear.ts rename to plugin-examples/linear/server/linear.ts diff --git a/plugin-examples/linear/issues.shared.ts b/plugin-examples/linear/shared/issues.ts similarity index 98% rename from plugin-examples/linear/issues.shared.ts rename to plugin-examples/linear/shared/issues.ts index fb16c388dd..63710398df 100644 --- a/plugin-examples/linear/issues.shared.ts +++ b/plugin-examples/linear/shared/issues.ts @@ -1,4 +1,4 @@ -import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin/server"; +import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin"; import { z } from "zod"; const SearchPayloadSchema = z.object({ diff --git a/plugin-examples/local-plugin/main.client.tsx b/plugin-examples/local-plugin/client/main.tsx similarity index 98% rename from plugin-examples/local-plugin/main.client.tsx rename to plugin-examples/local-plugin/client/main.tsx index d6ad3d644a..8bea0bf554 100644 --- a/plugin-examples/local-plugin/main.client.tsx +++ b/plugin-examples/local-plugin/client/main.tsx @@ -10,7 +10,7 @@ import { } from "@getpaseo/plugin"; import { useCallback, useMemo } from "react"; import { Pressable, Text, View } from "react-native"; -import { incrementRpc } from "./increment.shared"; +import { incrementRpc } from "../shared/increment"; export function OpenCounterPill({ theme, workspaceId, agentId }: PluginComposerPillProps) { const workspace = useWorkspace(workspaceId, ({ name }) => ({ name })); diff --git a/plugin-examples/local-plugin/increment.server.ts b/plugin-examples/local-plugin/increment.server.ts deleted file mode 100644 index 996cb4c378..0000000000 --- a/plugin-examples/local-plugin/increment.server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { output as ZodOutput } from "zod"; -import { incrementRpc } from "./increment.shared"; - -export function increment(input: ZodOutput) { - return { - value: input.value + 1, - handledBy: "plugin subprocess", - }; -} diff --git a/plugin-examples/local-plugin/index.client.tsx b/plugin-examples/local-plugin/index.client.tsx new file mode 100644 index 0000000000..5eee9b545d --- /dev/null +++ b/plugin-examples/local-plugin/index.client.tsx @@ -0,0 +1,23 @@ +import type { PluginClientContext } from "@getpaseo/plugin"; +import { contributeClient, ExamplePanel } from "./client/main"; + +export default function contribute(client: PluginClientContext) { + client.addWorkspacePanel({ + id: "counter", + title: "Plugin counter", + icon: "Blocks", + context: "workspace", + locations: ["workspace", "explorer"], + Component: ExamplePanel, + }); + client.addCommandCenterItem({ + id: "open-counter", + title: "Open plugin counter", + icon: "Blocks", + context: "workspace", + onSelect({ openPanel }) { + openPanel("counter"); + }, + }); + return contributeClient(client); +} diff --git a/plugin-examples/local-plugin/index.server.ts b/plugin-examples/local-plugin/index.server.ts new file mode 100644 index 0000000000..3b703a617c --- /dev/null +++ b/plugin-examples/local-plugin/index.server.ts @@ -0,0 +1,8 @@ +import type { PluginServerContext } from "@getpaseo/plugin"; +import { increment } from "./server/increment"; +import { incrementRpc } from "./shared/increment"; + +export default function contribute(server: PluginServerContext) { + server.handle(incrementRpc, increment); + return () => {}; +} diff --git a/plugin-examples/local-plugin/index.ts b/plugin-examples/local-plugin/index.ts deleted file mode 100644 index 6f6c81882f..0000000000 --- a/plugin-examples/local-plugin/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { PluginContext } from "@getpaseo/plugin"; -import { contributeClient, ExamplePanel } from "./main.client"; -import { increment } from "./increment.server"; -import { incrementRpc } from "./increment.shared"; - -export default function contribute(plugin: PluginContext) { - plugin.handle(incrementRpc, increment); - plugin.addWorkspacePanel({ - id: "counter", - title: "Plugin counter", - icon: "Blocks", - context: "workspace", - locations: ["workspace", "explorer"], - Component: ExamplePanel, - }); - plugin.addCommandCenterItem({ - id: "open-counter", - title: "Open plugin counter", - icon: "Blocks", - context: "workspace", - onSelect({ openPanel }) { - openPanel("counter"); - }, - }); - plugin.addClientSide(contributeClient); - return () => {}; -} diff --git a/plugin-examples/local-plugin/server/increment.ts b/plugin-examples/local-plugin/server/increment.ts new file mode 100644 index 0000000000..1efc1a4628 --- /dev/null +++ b/plugin-examples/local-plugin/server/increment.ts @@ -0,0 +1,9 @@ +import type { RpcInput } from "@getpaseo/plugin"; +import { incrementRpc } from "../shared/increment"; + +export function increment(input: RpcInput) { + return { + value: input.value + 1, + handledBy: "plugin subprocess", + }; +} diff --git a/plugin-examples/local-plugin/increment.shared.ts b/plugin-examples/local-plugin/shared/increment.ts similarity index 78% rename from plugin-examples/local-plugin/increment.shared.ts rename to plugin-examples/local-plugin/shared/increment.ts index 633d39104e..5d571ec4ef 100644 --- a/plugin-examples/local-plugin/increment.shared.ts +++ b/plugin-examples/local-plugin/shared/increment.ts @@ -1,4 +1,4 @@ -import { defineRpc } from "@getpaseo/plugin/server"; +import { defineRpc } from "@getpaseo/plugin"; import { z } from "zod"; export const incrementRpc = defineRpc({ diff --git a/plugin-examples/timeline-items/README.md b/plugin-examples/timeline-items/README.md index 6de6a21eda..847d7aa861 100644 --- a/plugin-examples/timeline-items/README.md +++ b/plugin-examples/timeline-items/README.md @@ -12,3 +12,6 @@ calls return `undefined`, leaving Paseo's original timeline entry unchanged. The transformer is a pure client contribution. It receives each source item and its streaming phase while Paseo builds the render model, then returns plain plugin item objects. The renderer validates `data` before Paseo mounts the component. + +`index.client.tsx` wires the transformer from `shared/pi-tasks.ts` and renderer from +`client/pi-tasks.tsx`. The plugin has no server entry or subprocess. diff --git a/plugin-examples/timeline-items/pi-tasks.client.tsx b/plugin-examples/timeline-items/client/pi-tasks.tsx similarity index 97% rename from plugin-examples/timeline-items/pi-tasks.client.tsx rename to plugin-examples/timeline-items/client/pi-tasks.tsx index efbad93c6f..4d7329a18a 100644 --- a/plugin-examples/timeline-items/pi-tasks.client.tsx +++ b/plugin-examples/timeline-items/client/pi-tasks.tsx @@ -2,7 +2,7 @@ import type { PluginTimelineItemProps } from "@getpaseo/plugin"; import { useMemo } from "react"; import { Text, View } from "react-native"; import type { z } from "zod"; -import { piTaskListSchema } from "./pi-tasks"; +import { piTaskListSchema } from "../shared/pi-tasks"; type TaskListData = z.output; diff --git a/plugin-examples/timeline-items/index.client.tsx b/plugin-examples/timeline-items/index.client.tsx new file mode 100644 index 0000000000..09724b095b --- /dev/null +++ b/plugin-examples/timeline-items/index.client.tsx @@ -0,0 +1,18 @@ +import type { PluginClientContext } from "@getpaseo/plugin"; +import { PiTaskList } from "./client/pi-tasks"; +import { piTaskListSchema, transformPiTodoToolCall } from "./shared/pi-tasks"; + +export default function contribute(client: PluginClientContext) { + client.addTimelineTransformer({ + id: "pi-tasks", + query: { itemType: "tool_call" }, + transform: transformPiTodoToolCall, + }); + client.addTimelineRenderer({ + kind: "pi-task-list", + version: 1, + schema: piTaskListSchema, + Component: PiTaskList, + }); + return () => {}; +} diff --git a/plugin-examples/timeline-items/index.ts b/plugin-examples/timeline-items/index.ts deleted file mode 100644 index 760d768a32..0000000000 --- a/plugin-examples/timeline-items/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { PluginContext } from "@getpaseo/plugin"; -import { PiTaskList } from "./pi-tasks.client"; -import { piTaskListSchema, transformPiTodoToolCall } from "./pi-tasks"; - -export default function contribute(plugin: PluginContext) { - plugin.addTimelineTransformer({ - id: "pi-tasks", - query: { itemType: "tool_call" }, - transform: transformPiTodoToolCall, - }); - plugin.addTimelineRenderer({ - kind: "pi-task-list", - version: 1, - schema: piTaskListSchema, - Component: PiTaskList, - }); - return () => {}; -} diff --git a/plugin-examples/timeline-items/pi-tasks.test.ts b/plugin-examples/timeline-items/pi-tasks.test.ts index 8a7e01acd7..80fbbcd579 100644 --- a/plugin-examples/timeline-items/pi-tasks.test.ts +++ b/plugin-examples/timeline-items/pi-tasks.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { transformPiTodoToolCall } from "./pi-tasks"; +import { transformPiTodoToolCall } from "./shared/pi-tasks"; function completedTodo(output: unknown) { return { diff --git a/plugin-examples/timeline-items/pi-tasks.ts b/plugin-examples/timeline-items/shared/pi-tasks.ts similarity index 100% rename from plugin-examples/timeline-items/pi-tasks.ts rename to plugin-examples/timeline-items/shared/pi-tasks.ts diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index 95b790e03b..d25961218a 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -9,24 +9,22 @@ category: Plugins # Plugin quickstart > **Experimental:** The plugin API is still evolving, so expect breaking changes and updates to -> your plugins as Paseo evolves. +> your plugins as Paseo evolves. See the [plugin roadmap](https://github.com/getpaseo/paseo/labels/plugins) +> for planned contribution surfaces. -See the [plugin roadmap](https://github.com/getpaseo/paseo/labels/plugins) for planned contribution -surfaces and their current status. +A plugin is a TypeScript project installed into one Paseo daemon. It can add +[surfaces and sidebar items](/docs/plugins/reference#surfaces-and-sidebar-items), +[workspace panels](/docs/plugins/reference#workspace-panels), +[Command Center items](/docs/plugins/reference#command-center-items), +[slash commands](/docs/plugins/reference#slash-commands), +[composer pills](/docs/plugins/reference#composer-pills), +[timeline items](/docs/plugins/reference#timeline-items), +[themes](/docs/plugins/reference#contribute-a-theme), +[attachment sources](/docs/plugins/reference#add-a-composer-attachment-source), and +[daemon-side RPCs](/docs/plugins/reference#add-plugin-specific-backend-behavior). Client +contributions run on every Paseo client connected to that daemon, including mobile. -Paseo plugins add native workspace panels, composer pills, Command Center items, global surfaces, app themes, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile. - -> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Server code and Git preparation commands run unsandboxed with the daemon user's access on the daemon host; client contributions run inside Paseo. Dependencies and future updates are part of that decision. With `--host`, commands run on the remote daemon host. - -On the target host, open **Settings → Plugins** and turn on **Enable plugins**. This is the global switch for every configured plugin on that daemon. - -You can also change the root `pluginsEnabled` field in the daemon's `config.json`, then apply it without restarting: - -```bash -paseo reload --json -``` - -Enabling starts configured plugins; disabling tears them down. Automation must inspect the current value first and obtain your explicit permission before changing a disabled or omitted value to `true`. +This guide scaffolds a plugin, runs it, and adds a workspace panel to it. ## Create a plugin @@ -38,14 +36,99 @@ cd /absolute/path/to/workspace-plugin npm install ``` -`init` creates a strict TypeScript project. It does not run the package manager. `npm install` -installs development dependencies for local typechecking and tests. Paseo supplies the plugin SDK, -React, React Native, TanStack Query, and Zod at runtime; plugins do not need a `build` hook for these -modules. `index.ts` registers contributions; client UI lives in `*.client.tsx` files. +`init` writes a strict TypeScript project and does not run the package manager. `npm install` adds +development dependencies for typechecking and tests only; Paseo supplies the plugin SDK, React, +React Native, TanStack Query, and Zod at runtime. -Plugins run on desktop, browser, iOS, and Android. Paseo ships several themes. Color every `Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`. Hardcoded black text fails in dark themes. +The scaffold is a working plugin: a sidebar surface with a button that asks the daemon for a +greeting through an RPC. -Replace `main.client.tsx` with: +```text +workspace-plugin/ + paseo-plugin.json # { "id": "workspace-plugin" } + index.client.tsx # runs in the Paseo app + index.server.ts # runs in a daemon subprocess + client/greeting.tsx # the surface component + client/web.ts # the only file allowed to touch browser APIs + server/greeting.ts # the RPC handler + shared/greeting.ts # the RPC contract, imported by both + package.json + tsconfig.json +``` + +Each entry default-exports one function that registers contributions and returns a cleanup +function. `index.client.tsx` registers the surface and the sidebar item that opens it: + +```tsx +import type { PluginClientContext } from "@getpaseo/plugin"; +import { GreetingSurface } from "./client/greeting"; + +export default function contribute(client: PluginClientContext) { + client.addSurface("greeting", GreetingSurface); + client.addSidebarItem({ + id: "greeting", + title: "Greeting", + icon: "MessageCircle", + surface: "greeting", + }); + return () => {}; +} +``` + +`index.server.ts` registers the handler for the contract in `shared/greeting.ts`: + +```ts +import type { PluginServerContext } from "@getpaseo/plugin"; +import { createGreeting } from "./server/greeting"; +import { greetingRpc } from "./shared/greeting"; + +export default function contribute(server: PluginServerContext) { + server.handle(greetingRpc, createGreeting); + return () => {}; +} +``` + +The directory is the boundary. Code under `client/` compiles only into the app bundle, code under +`server/` only into the daemon bundle, and `shared/` into both. Importing across that line, adding +a code file at the root, or importing a `node:` module from client code is a compile error. A +plugin with no daemon-side work can omit `index.server.ts`; a plugin with no UI can omit +`index.client.tsx`. + +Client code runs on phones as well as in browsers. The project typechecks without the DOM library, +so `document` and `window` are errors outside `client/web.ts`, which shows how to gate a browser +API behind `Platform.OS` with a native fallback. See +[Cross-platform rules](/docs/plugins/reference#cross-platform-rules) before writing UI. + +## Install and try it + +Plugins are trusted, unsandboxed code: server code and Git preparation commands run with the daemon +user's access on the daemon machine, and client code runs inside the Paseo app. Installing a plugin +means you trust that codebase, its dependencies, and its future updates. + +Turn on **Enable plugins** under **Settings → Plugins** on the daemon you are installing into. It is +the global switch for every plugin on that daemon. It is also the root `pluginsEnabled` field in the +daemon's `config.json`; after editing the file, apply it with `paseo reload --json`. An automated +tool must read the current value and get your explicit permission before turning it on. + +Then typecheck and install: + +```bash +npm run typecheck +paseo plugin install /absolute/path/to/workspace-plugin +paseo plugin ls +``` + +`paseo plugin ls` should report the plugin as `running`. Open Paseo, choose **Greeting** in the +sidebar, and press **Create greeting**. The message comes back from the daemon subprocess through +the RPC. + +If the sidebar item is missing, check that **Enable plugins** is on, the plugin is `running`, and +the client is viewing the host you installed into. `paseo plugin logs workspace-plugin` shows the +daemon-side output, including load errors. + +## Add a workspace panel + +A workspace panel opens as a tab next to agents, terminals, and files. Create `client/overview.tsx`: ```tsx import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin"; @@ -82,60 +165,67 @@ export function WorkspaceOverview({ theme, layout, workspaceId }: PluginWorkspac } ``` -Replace `index.ts` with: +`useWorkspace` reads the fields the panel renders from the app's cached state, without an RPC and +without re-rendering when unrelated fields change. Every `Text` takes its color from +`theme.colors`, and `layout.compact` drives spacing, so the panel works in every Paseo theme and on +phones. See [Theme and layout](/docs/plugins/reference#theme-and-layout) for the token list. -```ts -import type { PluginContext } from "@getpaseo/plugin"; -import { WorkspaceOverview } from "./main.client"; +Register the panel and a Command Center item that opens it by adding to `index.client.tsx`: -export default function contribute(plugin: PluginContext) { - plugin.addWorkspacePanel({ - id: "overview", - title: "Workspace overview", - icon: "PanelsTopLeft", - context: "workspace", - locations: ["workspace", "explorer"], - Component: WorkspaceOverview, - }); - plugin.addCommandCenterItem({ - id: "open-overview", - title: "Open workspace overview", - icon: "PanelsTopLeft", - context: "workspace", - onSelect({ openPanel }) { - openPanel("overview"); - }, - }); - return () => {}; -} +```tsx +import { WorkspaceOverview } from "./client/overview"; + +// Inside contribute(client), after the existing registrations: +client.addWorkspacePanel({ + id: "overview", + title: "Workspace overview", + icon: "PanelsTopLeft", + context: "workspace", + locations: ["workspace", "explorer"], + Component: WorkspaceOverview, +}); +client.addCommandCenterItem({ + id: "open-overview", + title: "Open workspace overview", + icon: "PanelsTopLeft", + context: "workspace", + onSelect({ openPanel }) { + openPanel("overview"); + }, +}); ``` -The icon is a [Lucide](https://lucide.dev/icons/) icon name. `*.client.tsx` files can use React Native runtime APIs; Paseo excludes them from the daemon bundle. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes. See [Theme and layout](/docs/plugins/reference#theme-and-layout) for the required tokens. +`icon` is a [Lucide](https://lucide.dev/icons/) icon name. -## Check and install it +## Edit and reload + +Source changes take effect only when you reload the plugin: ```bash npm run typecheck -paseo plugin install /absolute/path/to/workspace-plugin -paseo plugin ls +paseo plugin reload workspace-plugin ``` -Open a workspace, press **⌘K** on macOS or **Ctrl+K** on Windows and Linux, and choose **Open workspace overview**. It opens as a normal workspace tab. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it. +A reload stops the old plugin, runs its cleanup, compiles the current source, and starts it again. +A failed reload stays failed and reports its error in `paseo plugin ls`; fix the source and reload +again. -To install a plugin published through GitHub or another Git host: +Open a workspace, press **⌘K** on macOS or **Ctrl+K** on Windows and Linux, and choose **Open +workspace overview**. The panel opens as a workspace tab. + +## Install a published plugin + +Plugins published in a Git repository install by shorthand or URL: ```bash paseo plugin add owner/repository paseo plugin add https://gitlab.com/group/repository.git -paseo plugin add https://git.example.com/owner/repository.git paseo plugin add owner/monorepo:plugins/workspace paseo plugin add owner/repository --ref main ``` -Append `:relative/path` to the source when the plugin lives below the repository root. - -An omitted `--ref` tracks the default branch. Explicit branches track updates; tags and commits are -pinned. Check and apply updates with: +Append `:relative/path` when the plugin lives below the repository root. Without `--ref`, the +default branch is tracked; a branch tracks updates, while a tag or commit stays pinned. ```bash paseo plugin status @@ -143,60 +233,36 @@ paseo plugin update workspace-plugin paseo plugin update --all ``` -Most plugins should omit `build`. Paseo compiles TypeScript and TSX and supplies its runtime modules. -Declare preparation only when the staged checkout must install another dependency, generate source, -or perform another required build step: +Paseo compiles TypeScript itself, so most plugins need no build step. A repository that must +install a dependency Paseo does not provide, or generate files, declares +[`build` commands](/docs/plugins/reference#cli-reference) in its manifest. -```json -{ - "id": "workspace-plugin", - "build": [ - ["npm", "ci"], - ["npm", "run", "build"] - ] -} -``` +## Read backend logs -Each `build` entry is a non-empty argv array, executed directly without a shell from the staged -plugin directory. Paseo never chooses a package manager or infers commands from lockfiles. On -install and update it resolves the exact commit, runs these commands, then validates, compiles, and -activates the candidate. A failed command discards the candidate and keeps the installed/running -version. The daemon log records the exact argv and output; `--host` runs them on the remote daemon -host. - -## Edit and reload - -Source changes are explicit: - -```bash -npm run typecheck -paseo plugin reload workspace-plugin -``` - -A reload stops the old plugin, runs its cleanup, compiles the current source, and starts it again. A failed reload stays failed and reports its load error; fix the source and reload again. - -## Debug backend output - -Use normal Node logging in daemon-side handlers and cleanup: +Daemon-side handlers and cleanup can use normal Node logging: ```ts console.log("Refreshing issues"); console.error("Issue refresh failed", error); ``` -Read recent stdout and stderr from **Settings → Plugins → Logs** or the CLI: +Read the recent output from **Settings → Plugins → Logs** or the CLI: ```bash paseo plugin logs workspace-plugin paseo plugin logs workspace-plugin --json ``` -The log tail includes `[paseo]` loading, ready, stopping, and stopped entries, plus compilation and -load failures. It survives reloads and crashes. Inspect it when a plugin fails to start or an RPC -rejects. See [Debug backend output](/docs/plugins/reference#debug-backend-output) for retention and -security behavior. +The tail includes `[paseo]` loading, ready, stopping, and stopped entries, plus compilation and load +failures, and it survives reloads and crashes. Client-side output stays in the app. See +[Debug backend output](/docs/plugins/reference#debug-backend-output) for retention and what not to +log. ## Next -- [Plugin reference](/docs/plugins/reference), add daemon behavior, use the Paseo SDK, contribute themes and attachments, and manage lifecycle. -- [TypeScript SDK](/docs/sdk), the workspace, agent, provider, and config API exposed inside plugins. +- [Plugin reference](/docs/plugins/reference): every contribution type, its fields, the runtime + modules, hosts, and the CLI. +- [Migrate a plugin to runtime entries](/docs/plugins/migration): move a plugin written against the + single `index.ts` entry, step by step. +- [TypeScript SDK](/docs/sdk): the workspace, agent, provider, and config API available as `paseo` + in client and server code. diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md new file mode 100644 index 0000000000..cae6bc9c7c --- /dev/null +++ b/public-docs/plugins/migration.md @@ -0,0 +1,233 @@ +--- +title: Migrate a plugin to runtime entries +description: Mechanical migration from a mixed plugin entry to explicit client and server entries. +nav: Migration +order: 47 +category: Plugins +--- + +# Migrate a plugin to runtime entries + +Give this page to a coding agent with the plugin directory as its working directory. Execute the +steps in order. Do not keep a compatibility entry. + +## 1. Classify the existing code + +Start from the old shape: + +```text +my-plugin/ + paseo-plugin.json + package.json + tsconfig.json + index.ts + greeting.client.tsx + greeting.server.ts + greeting.shared.ts +``` + +The finished shape is: + +```text +my-plugin/ + paseo-plugin.json + package.json + tsconfig.json + index.client.tsx + index.server.ts + client/greeting.tsx + server/greeting.ts + shared/greeting.ts +``` + +Create only the entries the plugin needs. At least one is required. Components and client callbacks +need the client entry. RPC handlers and Node APIs need the server entry. + +## 2. Rename files and directories + +Apply these rules exactly: + +1. Replace the mixed root entry with `index.client.tsx`, `index.server.ts`, or both. +2. Move every `name.client.ts` or `name.client.tsx` to `client/name.ts` or `client/name.tsx`. +3. Move every `name.server.ts` or `name.server.tsx` to `server/name.ts` or `server/name.tsx`. +4. Move every `name.shared.ts` or `name.shared.tsx` to `shared/name.ts` or `shared/name.tsx`. +5. Preserve nested feature directories under the matching runtime directory. +6. Update relative imports after every move. +7. Keep `paseo-plugin.json`, `package.json`, and `tsconfig.json` at the root. +8. Delete the old root entry. Paseo does not load it. + +The directories are the compiler boundaries. A file beneath `client/` compiles only into the app +bundle, a file beneath `server/` only into the daemon bundle, and `shared/` into both. Filename +suffixes such as `*.client.tsx` no longer mean anything, and a code module left at the plugin root +is a compile error. + +## 3. Move every registration + +Use this table as the complete registration checklist. + +| Old registration and location | New registration and location | +| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` | +| `plugin.addSurface(id, Component)` in the old root entry | `client.addSurface(id, Component)` in `index.client.tsx` | +| `plugin.addSidebarItem(item)` in the old root entry | `client.addSidebarItem(item)` in `index.client.tsx` | +| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` | +| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` | +| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` | +| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function | +| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function | +| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` | +| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` | +| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` | +| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` | +| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` | +| `ZodOutput` handler parameter types | `RpcInput` from `@getpaseo/plugin`; `RpcOutput` for return types | + +Import `PluginClientContext` in the client entry and `PluginServerContext` in the server entry. +Remove imports of the old context type. `@getpaseo/plugin/server` now exports only handler-side +types such as `PluginHandlerContext`. Every client `add*` now returns an idempotent removal +function. Preserve any remover the plugin calls before teardown; Paseo removes outstanding +registrations after the entry cleanup runs. + +## 4. Separate imports + +The client entry imports only `client/`, `shared/`, and client-safe packages. The server entry imports +only `server/`, `shared/`, and server-safe packages. A `node:` import in the client entry or anything +reachable from it is a compile error. Never import a component into the server entry merely to wire +its registration; that registration belongs in the client entry. + +## 5. Recognize half-migration errors + +| Compiler or load error | Meaning and fix | +| -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `Plugin entry split is required` | The directory still has only the old root entry. Create a runtime entry, move registrations, then delete the old file. | +| `Plugin entry points are missing: expected index.client.ts or index.client.tsx and/or index.server.ts or index.server.tsx` | No supported entry exists. Add at least one exact filename. | +| `server-only module cannot be imported into the plugin client bundle: ` | A client import reaches `server/`. Move the call behind an RPC and import its contract from `shared/`. | +| `client-only module cannot be imported into the plugin server bundle: ` | A server import reaches `client/`. Move that registration and import to the client entry. | +| `Plugin modules belong in client/, server/, or shared/: ` | A code module is still at the plugin root. Move it into the matching directory and fix its imports. | +| `Node module cannot be imported into the plugin client bundle: node: imported by ` | Client code imports a Node API. Move the operation to `server/`, expose an RPC in `shared/`, and call it from the client. | +| TypeScript reports that `PluginContext`, `addClientSide`, or `addClientSlashCommand` does not exist | Replace the old context types and registrations using the table above. | + +## 6. Worked example: `plugin-examples/local-plugin` + +Only the entry files and import paths change. Component and handler bodies move without edits. + +Before: + +```text +local-plugin/ + index.ts + main.client.tsx + increment.server.ts + increment.shared.ts +``` + +```ts +// index.ts +import type { PluginContext } from "@getpaseo/plugin"; +import { contributeClient, ExamplePanel } from "./main.client"; +import { increment } from "./increment.server"; +import { incrementRpc } from "./increment.shared"; + +export default function contribute(plugin: PluginContext) { + plugin.handle(incrementRpc, increment); + plugin.addWorkspacePanel({ + id: "counter", + title: "Plugin counter", + icon: "Blocks", + context: "workspace", + locations: ["workspace", "explorer"], + Component: ExamplePanel, + }); + plugin.addCommandCenterItem({ + id: "open-counter", + title: "Open plugin counter", + icon: "Blocks", + context: "workspace", + onSelect({ openPanel }) { + openPanel("counter"); + }, + }); + plugin.addClientSide(contributeClient); + return () => {}; +} +``` + +After: + +```text +local-plugin/ + index.client.tsx + index.server.ts + client/main.tsx # was main.client.tsx + server/increment.ts # was increment.server.ts + shared/increment.ts # was increment.shared.ts +``` + +```tsx +// index.client.tsx +import type { PluginClientContext } from "@getpaseo/plugin"; +import { contributeClient, ExamplePanel } from "./client/main"; + +export default function contribute(client: PluginClientContext) { + client.addWorkspacePanel({ + id: "counter", + title: "Plugin counter", + icon: "Blocks", + context: "workspace", + locations: ["workspace", "explorer"], + Component: ExamplePanel, + }); + client.addCommandCenterItem({ + id: "open-counter", + title: "Open plugin counter", + icon: "Blocks", + context: "workspace", + onSelect({ openPanel }) { + openPanel("counter"); + }, + }); + return contributeClient(client); +} +``` + +```ts +// index.server.ts +import type { PluginServerContext } from "@getpaseo/plugin"; +import { increment } from "./server/increment"; +import { incrementRpc } from "./shared/increment"; + +export default function contribute(server: PluginServerContext) { + server.handle(incrementRpc, increment); + return () => {}; +} +``` + +Import path changes inside the moved files: + +```diff + // client/main.tsx +-import { incrementRpc } from "./increment.shared"; ++import { incrementRpc } from "../shared/increment"; + + // server/increment.ts +-import { incrementRpc } from "./increment.shared"; ++import { incrementRpc } from "../shared/increment"; +``` + +`contributeClient` already took a `PluginClientContext` and returned cleanup, so the client entry +calls it directly and returns its cleanup. A plugin whose `addClientSide` callback also registered +pills or subscriptions keeps that code; only the wrapper goes away. + +## 7. Verify the migration + +Run: + +```bash +npm run typecheck +paseo plugin reload +paseo plugin ls +``` + +Require `running` with no error. Exercise every contribution. For plugins with RPCs, call the client +action and verify the server result. For client-only plugins, confirm the contribution loads without +a server process. Call any stored registration remover twice and verify the second call is a no-op. diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 0b7657804c..2a47172be0 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -8,14 +8,16 @@ category: Plugins # Plugin reference +Migrating an existing plugin? Follow the standalone [runtime-entry migration guide](/docs/plugins/migration). + Local plugins are directory sources installed into one Paseo daemon. A plugin can contribute: - React Native surfaces and sidebar items to Paseo clients; - workspace and agent panels opened as workspace tabs; - global, workspace, and agent actions in the Command Center; -- client slash commands in the message composer; +- slash commands in the message composer; - transformed and daemon-pushed agent timeline rows; -- dark themes in Settings → Appearance; +- light and dark themes in Settings → Appearance; - schema-validated RPC handlers running beside the daemon; - normal Paseo operations through the TypeScript SDK; - searchable external resources in the message composer. @@ -29,8 +31,11 @@ Plugin code is trusted and unsandboxed. Client surfaces run in the Paseo app. Ba ```text my-plugin/ paseo-plugin.json - index.ts - main.client.tsx + index.client.tsx + index.server.ts + client/greeting.tsx + server/greeting.ts + shared/greeting.ts package.json tsconfig.json ``` @@ -41,70 +46,114 @@ The required root manifest is `paseo-plugin.json`. It contains the default plugi { "id": "my-plugin" } ``` -The entry point is `index.ts` at the plugin root. Plugin, surface, sidebar-item, workspace-panel, -Command Center item, and attachment-source IDs start with a lowercase letter and contain lowercase -letters, numbers, or hyphens. Client slash-command names follow the same rule. +| Entry | Runtime | Receives | Required | +| ------------------ | --------------------- | --------------------- | ----------------------------------------------------------------- | +| `index.client.tsx` | Paseo app, per client | `PluginClientContext` | When the plugin has any UI, callback, theme, or attachment source | +| `index.server.ts` | Daemon subprocess | `PluginServerContext` | When the plugin handles RPCs | + +At least one entry is required; both accept `.ts` or `.tsx`. A directory that still has only the +old `index.ts` fails to load and points at the [migration guide](/docs/plugins/migration). + +Plugin, surface, sidebar-item, workspace-panel, Command Center item, attachment-source, and +slash-command IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens. The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development dependencies for local typechecking and tests. Paseo supplies their runtime instances. Consumers do not install them when adding the plugin. -Add runtime-specific files as the plugin grows: +Every other module lives in one of three directories. Nesting inside them is fine; a module at the +plugin root is a compile error. -```text -my-plugin/ - action.shared.ts - action.server.ts - panel.client.tsx -``` - -| Suffix | Use it for | -| -------------- | -------------------------------------------------------------------- | -| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, and callbacks. | -| `*.server.ts` | Node APIs, local resources, credentials, and RPC handlers. | -| `*.shared.ts` | Zod RPC contracts and plain values imported by both runtimes. | +| Directory | Compiled into | Use it for | +| --------- | ------------------ | -------------------------------------------------------------------- | +| `client/` | App bundle only | React, React Native, hooks, styles, surfaces, panels, and callbacks. | +| `server/` | Daemon bundle only | Node APIs, local resources, credentials, and RPC handlers. | +| `shared/` | Both | Zod RPC contracts and plain values imported by both runtimes. | ## Runtime modules -Paseo builds separate client and server bundles from `index.ts`. It rejects imports from `*.server` files into client modules and imports from `*.client` files into server modules. Keep shared modules free of Node and React Native runtime code. +Paseo builds each bundle from its matching entry. An import from `client/` into the daemon bundle, +from `server/` into the app bundle, or of a `node:` module anywhere in the app bundle is a compile +error. Keep `shared/` free of Node and React Native runtime code. ### Client runtime Paseo provides these modules to client code: -| Module | Use it for | -| ------------------------------- | ------------------------------------- | -| `@getpaseo/plugin` | Contribution contracts and data hooks | -| `@getpaseo/plugin/react-native` | Paseo UI components and UI hooks | -| `@getpaseo/plugin/server` | Shared RPC and attachment contracts | -| `@tanstack/react-query` | Request state and caching | -| `react` | Components and hooks | -| `react/jsx-runtime` | Compiled JSX | -| `react-native` | Cross-platform UI | -| `zod` | Shared schemas | +| Module | Use it for | +| ------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `@getpaseo/plugin` | Contribution contracts, `defineRpc`, `defineAttachmentSource`, `RpcInput`, `RpcOutput`, and data hooks | +| `@getpaseo/plugin/react-native` | Paseo UI components and UI hooks | +| `@getpaseo/plugin/server` | Handler-only types such as `PluginHandlerContext` | +| `@tanstack/react-query` | Request state and caching | +| `react` | Components and hooks | +| `react/jsx-runtime` | Compiled JSX | +| `react-native` | Cross-platform UI | +| `zod` | Shared schemas | These exact module specifiers use the host's runtime instances. A client bundle that requests another host module fails with `Module "" is not available in plugin client code`. Do not import `lucide-react-native`, `react-native-svg`, or DOM libraries. Set contribution `icon` fields to a [Lucide icon name](https://lucide.dev/icons/); Paseo validates the name and renders the icon. -Client components are React Native components rendered by Paseo. Web clients render them through React Native Web. Browser globals such as `localStorage` and `location` exist only when `layout.platform === "web"`; iOS and Android have no equivalent. Gate any use on that field. +### Cross-platform rules -There is no plugin storage API. Browser storage does not persist settings across Paseo clients. There is also no general host navigation API: plugin code cannot open native Paseo routes. Command Center callbacks can only open surfaces and panels registered by the same plugin. +Client code runs on iOS, Android, and in browsers through React Native Web. A component that works +in your browser and crashes on a phone is the most common plugin bug. The rules: + +| Do | Do not | +| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `View`, `Text`, `Pressable`, `ScrollView`, `TextInput` from `react-native` | `
`, ``, `