mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
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.
This commit is contained in:
+89
-52
@@ -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 `<Modal title="…" icon={…}>` 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 `/// <reference lib="dom" />`, 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.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-0hOGev0HglOQmofzPQMfiWh1opg6cpiEgsfK22AKcGk=
|
||||
sha256-R5NuJTwMOYPo0i60qhF0lZU+OMHwjnIKf9gI1fCJ9/M=
|
||||
|
||||
Generated
-47
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<void> {
|
||||
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");
|
||||
|
||||
@@ -30,10 +30,16 @@ export async function expectDiagramRemainsRenderedWhileStreaming(
|
||||
completion: Promise<void>,
|
||||
): Promise<void> {
|
||||
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,
|
||||
|
||||
+10
-32
@@ -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["clientSide"]>): InstalledPlugin {
|
||||
function installation(): InstalledPlugin {
|
||||
return {
|
||||
id: "review",
|
||||
serverId: "host-a",
|
||||
@@ -21,7 +21,7 @@ function installation(clientSide: NonNullable<InstalledPlugin["clientSide"]>): 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",
|
||||
+11
-34
@@ -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: {
|
||||
@@ -97,7 +97,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
onSelect: onAgentSelect,
|
||||
},
|
||||
],
|
||||
clientSide: null,
|
||||
clientSlashCommands: [],
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<EvaluatedPlugin, "id" | "cleanup"> = {
|
||||
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<string>();
|
||||
const timelineTransformerIds = new Set<string>();
|
||||
const timelineRendererIds = new Set<string>();
|
||||
const pluginContext = createPluginContext({
|
||||
const removals = new Set<PluginCleanup>();
|
||||
let setupComplete = false;
|
||||
const notifyChange = () => {
|
||||
if (setupComplete) onChange();
|
||||
};
|
||||
function register<T>(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<PluginSurfaceProps>) {
|
||||
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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof registry.installCatalog>[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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -21,7 +21,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
|
||||
],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
clientSlashCommands: [],
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -26,7 +26,7 @@ function installation(
|
||||
})),
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
clientSlashCommands: [],
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -54,7 +54,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
clientSlashCommands: [],
|
||||
attachmentSources: [],
|
||||
themes,
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -17,7 +17,7 @@ function plugin(input: {
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
clientSlashCommands: [],
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [
|
||||
|
||||
@@ -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<ReturnType<typeof createRoot>> = [];
|
||||
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(
|
||||
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={timelineItem} />,
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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]!;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function installed(): InstalledPlugin {
|
||||
},
|
||||
],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
clientSlashCommands: [],
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -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<void> {
|
||||
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(
|
||||
`/// <reference lib="dom" />`,
|
||||
);
|
||||
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<typeof inspect.input>,
|
||||
_input: RpcInput<typeof inspect>,
|
||||
{ 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 () => {
|
||||
|
||||
@@ -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<typeof greetingRpc>) {
|
||||
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 (
|
||||
<View style={styles.screen}>
|
||||
<Text style={styles.text}>Hello from my plugin</Text>
|
||||
<Text style={styles.text}>{greeting.data?.message ?? "Ask the daemon for a greeting."}</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Create greeting"
|
||||
style={styles.button}
|
||||
onPress={() => greeting.mutate({ name: "Paseo" })}
|
||||
>
|
||||
<Text style={styles.buttonText}>Create greeting</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Open the Paseo website"
|
||||
style={styles.button}
|
||||
onPress={() => openExternal("https://paseo.sh")}
|
||||
>
|
||||
<Text style={styles.buttonText}>Open paseo.sh</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
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<void> {
|
||||
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" }),
|
||||
|
||||
@@ -27,7 +27,7 @@ async function main(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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"]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -127,7 +127,20 @@ export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
|
||||
}
|
||||
|
||||
export interface PluginClientContext extends PluginCommandCapabilities {
|
||||
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): PluginCleanup;
|
||||
addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup;
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup;
|
||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup;
|
||||
addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup;
|
||||
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup;
|
||||
addTheme(contribution: PluginThemeContribution): PluginCleanup;
|
||||
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
|
||||
contribution: PluginTimelineTransformerContribution<ItemType>,
|
||||
): PluginCleanup;
|
||||
addTimelineRenderer<Schema extends ZodType>(
|
||||
contribution: PluginTimelineRendererContribution<Schema>,
|
||||
): PluginCleanup;
|
||||
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
|
||||
}
|
||||
|
||||
@@ -296,7 +309,7 @@ export interface PluginHandlerContext {
|
||||
paseo: PaseoApi;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
export interface PluginServerContext {
|
||||
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
handler: (
|
||||
@@ -304,22 +317,8 @@ export interface PluginContext {
|
||||
context: PluginHandlerContext,
|
||||
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
|
||||
): void;
|
||||
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): 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<ItemType extends AgentTimelineItem["type"]>(
|
||||
contribution: PluginTimelineTransformerContribution<ItemType>,
|
||||
): void;
|
||||
addTimelineRenderer<Schema extends ZodType>(
|
||||
contribution: PluginTimelineRendererContribution<Schema>,
|
||||
): void;
|
||||
}
|
||||
|
||||
export type PluginCleanup = () => void | Promise<void>;
|
||||
|
||||
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
|
||||
export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup;
|
||||
|
||||
@@ -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<RpcInput<typeof contract>>().toEqualTypeOf<{ value: number }>();
|
||||
expectTypeOf<RpcOutput<typeof contract>>().toEqualTypeOf<{ value: number }>();
|
||||
});
|
||||
|
||||
it("validates input before invoking the daemon", async () => {
|
||||
let invocations = 0;
|
||||
const invoke = async () => {
|
||||
|
||||
@@ -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<PluginSurfaceProps>): 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,
|
||||
|
||||
@@ -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 extends PluginAttachmentSourceContribution>(
|
||||
definition: Definition,
|
||||
): Definition {
|
||||
return definition;
|
||||
}
|
||||
|
||||
export declare const Icon: ComponentType<PluginIconProps>;
|
||||
export type {
|
||||
@@ -26,8 +30,8 @@ export type {
|
||||
PluginClientOpenPanelOptions,
|
||||
PluginComposerPillContribution,
|
||||
PluginComposerPillProps,
|
||||
PluginContribution,
|
||||
PluginContext,
|
||||
PluginServerContribution,
|
||||
PluginServerContext,
|
||||
PluginGlobalCommandContext,
|
||||
PluginHandlerContext,
|
||||
PluginHostProps,
|
||||
|
||||
@@ -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}(`);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,9 @@ export interface PluginRpcContract<
|
||||
output: OutputSchema;
|
||||
}
|
||||
|
||||
export type RpcInput<Contract extends PluginRpcContract> = ZodOutput<Contract["input"]>;
|
||||
export type RpcOutput<Contract extends PluginRpcContract> = ZodInput<Contract["output"]>;
|
||||
|
||||
interface RpcDefinition<InputSchema extends ZodType, OutputSchema extends ZodType> {
|
||||
name: string;
|
||||
input: InputSchema;
|
||||
|
||||
@@ -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 extends PluginAttachmentSourceContribution>(
|
||||
definition: Definition,
|
||||
): Definition {
|
||||
return definition;
|
||||
}
|
||||
export type { PluginHandlerContext } from "./contracts.js";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"lib": ["ES2023"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <Text>Automatic JSX</Text>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("automatic-jsx", Surface);
|
||||
import { clientLabel } from "../shared/labels";
|
||||
export function Surface() { return <Text>{clientLabel}</Text>; }`,
|
||||
),
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PluginBuildTarget, ReadonlySet<string>> = {
|
||||
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<string>,
|
||||
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<string>();
|
||||
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<string> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ async function createPlugin(id: string, source: string): Promise<string> {
|
||||
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",
|
||||
|
||||
@@ -22,7 +22,7 @@ async function createRepository(): Promise<string> {
|
||||
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 });
|
||||
|
||||
@@ -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;
|
||||
}`,
|
||||
);
|
||||
|
||||
@@ -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<unknown>;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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 <Text>Workspace review panel</Text>;
|
||||
}
|
||||
|
||||
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<typeof inspectRpc.input>) {
|
||||
return { platform: platform() };
|
||||
@@ -663,6 +730,10 @@ export function inspectHost(_input: z.input<typeof inspectRpc.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<typeof inspectRpc.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",
|
||||
),
|
||||
|
||||
@@ -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<string>;
|
||||
child: PluginChild;
|
||||
outputCapture: PluginOutputCapture;
|
||||
child: PluginChild | null;
|
||||
outputCapture: PluginOutputCapture | null;
|
||||
pending: Map<string, PendingInvocation>;
|
||||
sessionSocket: PluginSessionSocket;
|
||||
sessionClosed: Promise<void>;
|
||||
sessionSocket: PluginSessionSocket | null;
|
||||
sessionClosed: Promise<void> | null;
|
||||
}
|
||||
|
||||
interface PluginRuntimeDependencies {
|
||||
@@ -187,13 +186,33 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveEntryPath(directory: string): Promise<string> {
|
||||
for (const filename of [ENTRY_FILENAME, LEGACY_ENTRY_FILENAME]) {
|
||||
async function findEntry(directory: string, filenames: readonly string[]): Promise<string | null> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
@@ -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<LoadedPlugin> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void>((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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -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`.
|
||||
|
||||
+5
-5
@@ -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,
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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<typeof searchIssuesRpc.input>): Promise<PluginAttachmentSearchPayload> {
|
||||
const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" });
|
||||
return linear.search(query);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof searchIssuesRpc>): Promise<PluginAttachmentSearchPayload> {
|
||||
const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" });
|
||||
return linear.search(query);
|
||||
}
|
||||
@@ -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({
|
||||
+1
-1
@@ -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 }));
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { output as ZodOutput } from "zod";
|
||||
import { incrementRpc } from "./increment.shared";
|
||||
|
||||
export function increment(input: ZodOutput<typeof incrementRpc.input>) {
|
||||
return {
|
||||
value: input.value + 1,
|
||||
handledBy: "plugin subprocess",
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { RpcInput } from "@getpaseo/plugin";
|
||||
import { incrementRpc } from "../shared/increment";
|
||||
|
||||
export function increment(input: RpcInput<typeof incrementRpc>) {
|
||||
return {
|
||||
value: input.value + 1,
|
||||
handledBy: "plugin subprocess",
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { defineRpc } from "@getpaseo/plugin/server";
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
|
||||
export const incrementRpc = defineRpc({
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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<typeof piTaskListSchema>;
|
||||
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+163
-97
@@ -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.
|
||||
|
||||
@@ -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<typeof contract.input>` handler parameter types | `RpcInput<typeof contract>` 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: <file>` | 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: <file>` | A server import reaches `client/`. Move that registration and import to the client entry. |
|
||||
| `Plugin modules belong in client/, server/, or shared/: <file>` | 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:<name> imported by <file>` | 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 <plugin-id>
|
||||
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.
|
||||
+192
-128
@@ -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 "<name>" 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` | `<div>`, `<span>`, `<button>`, or any HTML element |
|
||||
| `style` objects built from `theme.colors` and `layout.compact` | `className`, CSS strings, or hardcoded colors |
|
||||
| `onPress` | `onClick`, `onMouseEnter`, or other DOM handlers |
|
||||
| `Linking`, `Clipboard`-style React Native APIs | `window`, `document`, `localStorage`, `navigator`, `location` in components |
|
||||
|
||||
The scaffold's `tsconfig.json` omits the DOM library, so `document` and `window` are type errors
|
||||
everywhere by default. The one place browser APIs are allowed is `client/web.ts`. It declares the
|
||||
narrow shape of each global it uses, gates every export on `Platform.OS`, and gives native the
|
||||
alternative:
|
||||
|
||||
`client/web.ts`:
|
||||
|
||||
```ts
|
||||
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<void> {
|
||||
if (Platform.OS === "web") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
await Linking.openURL(url);
|
||||
}
|
||||
```
|
||||
|
||||
Do not add `/// <reference lib="dom" />` or `"DOM"` to `lib`; either one turns DOM types back on
|
||||
for the whole project and hides the next mistake. Components import `openExternal` and never touch
|
||||
`window` themselves. `layout.platform` on surface and panel props carries the same value as
|
||||
`Platform.OS` for rendering decisions.
|
||||
|
||||
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.
|
||||
|
||||
### Server runtime
|
||||
|
||||
Paseo provides `@getpaseo/plugin`, `@getpaseo/plugin/server`, and `zod` to server code. Backend contributions run in a daemon subprocess with Node access to the host machine. Keep filesystem, process, credential, and other machine-local work in `*.server.ts` files.
|
||||
Paseo provides `@getpaseo/plugin`, `@getpaseo/plugin/server`, and `zod` to server code. Backend contributions run in a daemon subprocess with Node access to the host machine. Keep filesystem, process, credential, and other machine-local work under `server/`. A plugin without `index.server.ts` starts no subprocess.
|
||||
|
||||
## Entry point and cleanup
|
||||
|
||||
`index.ts` wires contributions together and default-exports one contribution function. It must return cleanup, even when it has nothing to clean:
|
||||
Each present entry default-exports one contribution function and returns cleanup. Client entries
|
||||
receive `PluginClientContext`; server entries receive `PluginServerContext`. Every client `add*`
|
||||
returns an idempotent remover. The entry cleanup runs before Paseo removes remaining registrations.
|
||||
|
||||
```ts
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { Main } from "./main.client";
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { Main } from "./client/main";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.addSurface("main", Main);
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addSurface("main", Main);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
@@ -115,7 +164,7 @@ Cleanup can be async. Release timers, watchers, sockets, and other resources cre
|
||||
|
||||
Register a component, then point a sidebar item at its surface ID:
|
||||
|
||||
`main.client.tsx`:
|
||||
`client/main.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { PluginSurfaceProps } from "@getpaseo/plugin";
|
||||
@@ -144,15 +193,15 @@ export function Main({ theme, host, layout }: PluginSurfaceProps) {
|
||||
}
|
||||
```
|
||||
|
||||
`index.ts`:
|
||||
`index.client.tsx`:
|
||||
|
||||
```ts
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { Main } from "./main.client";
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { Main } from "./client/main";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.addSurface("main", Main);
|
||||
plugin.addSidebarItem({
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addSurface("main", Main);
|
||||
client.addSidebarItem({
|
||||
id: "main",
|
||||
title: "My plugin",
|
||||
icon: "Blocks",
|
||||
@@ -175,7 +224,7 @@ Paseo owns the route, header, close action, host picker, error boundary, and que
|
||||
|
||||
## Host UI
|
||||
|
||||
Import Paseo-owned UI from `@getpaseo/plugin/react-native` in `*.client.tsx` files. This example
|
||||
Import Paseo-owned UI from `@getpaseo/plugin/react-native` in client code. This example
|
||||
opens a controlled modal, renders a host icon, and confirms the action with a toast:
|
||||
|
||||
```tsx
|
||||
@@ -280,7 +329,7 @@ registrations are client contributions. Paseo applies the transformer while buil
|
||||
model, including every live streaming update.
|
||||
|
||||
```tsx
|
||||
import type { PluginContext, PluginTimelineItemProps } from "@getpaseo/plugin";
|
||||
import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin";
|
||||
import { Text } from "react-native";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -290,8 +339,8 @@ function Card({ item, theme }: PluginTimelineItemProps<z.output<typeof schema>>)
|
||||
return <Text style={{ color: theme.colors.foreground }}>{item.data.label}</Text>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.addTimelineTransformer({
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addTimelineTransformer({
|
||||
id: "command-card",
|
||||
query: { itemType: "tool_call" },
|
||||
transform({ item, phase }) {
|
||||
@@ -307,7 +356,7 @@ export default function contribute(plugin: PluginContext) {
|
||||
};
|
||||
},
|
||||
});
|
||||
plugin.addTimelineRenderer({
|
||||
client.addTimelineRenderer({
|
||||
kind: "command-card",
|
||||
version: 1,
|
||||
schema,
|
||||
@@ -391,14 +440,14 @@ Workspace and agent panels receive the same `theme`, `layout`, and optional `nav
|
||||
|
||||
## Contribute a theme
|
||||
|
||||
`addTheme` adds a light or dark theme to Settings → Appearance, listed under the built-ins by its `name`. A
|
||||
theme is data, so it needs no client file:
|
||||
`addTheme` adds a light or dark theme to Settings → Appearance, listed under the built-ins by its
|
||||
`name`. A theme is data, so it needs no component file:
|
||||
|
||||
```ts
|
||||
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",
|
||||
@@ -439,15 +488,14 @@ Only one contributed theme is active at a time. Selecting one persists the choic
|
||||
later disabled or removed, Paseo falls back to the default theme rather than leaving the app
|
||||
unpainted.
|
||||
|
||||
Themes need a host that supports them. A daemon released before `addTheme` compiles the call into
|
||||
the plugin's backend bundle, where it does not exist, and the plugin fails to start with
|
||||
`plugin.addTheme is not a function`. Update the host.
|
||||
Themes need a host that supports them. A client released before `addTheme` cannot evaluate that client entry and reports
|
||||
`client.addTheme is not a function`. Update the client.
|
||||
|
||||
## Workspace panels
|
||||
|
||||
Register one panel for workspace or agent context:
|
||||
|
||||
`review.client.tsx`:
|
||||
`client/review.tsx`:
|
||||
|
||||
```tsx
|
||||
import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
@@ -478,14 +526,14 @@ export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgent
|
||||
}
|
||||
```
|
||||
|
||||
`index.ts`:
|
||||
`index.client.tsx`:
|
||||
|
||||
```ts
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { ReviewPanel } from "./review.client";
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { ReviewPanel } from "./client/review";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.addWorkspacePanel({
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addWorkspacePanel({
|
||||
id: "review",
|
||||
title: "Review",
|
||||
icon: "Scan",
|
||||
@@ -562,7 +610,7 @@ Open the Command Center with **⌘K** on macOS or **Ctrl+K** on Windows and Linu
|
||||
Register an action and open a panel from the callback:
|
||||
|
||||
```tsx
|
||||
import { defineRpc } from "@getpaseo/plugin/server";
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
|
||||
const refreshReview = defineRpc({
|
||||
@@ -571,7 +619,7 @@ const refreshReview = defineRpc({
|
||||
output: z.object({ refreshed: z.boolean() }),
|
||||
});
|
||||
|
||||
plugin.addCommandCenterItem({
|
||||
client.addCommandCenterItem({
|
||||
id: "open-review",
|
||||
title: "Open review",
|
||||
icon: "Scan",
|
||||
@@ -612,13 +660,13 @@ Every callback receives:
|
||||
|
||||
An agent callback may open either an agent panel or a workspace panel. A workspace callback may open only a workspace panel. Unknown surface and panel IDs fail visibly. Use `paseo` for normal workspace, agent, provider, and daemon-config operations. Use `rpc` for plugin-specific filesystem, credential, vendor, or daemon-local work.
|
||||
|
||||
## Client slash commands
|
||||
## Slash commands
|
||||
|
||||
Register a command that runs entirely in the Paseo client when the user submits it from the message
|
||||
composer:
|
||||
Register a command that runs in the Paseo client when the user submits `/name args` from the
|
||||
message composer. The text is never sent to the agent:
|
||||
|
||||
```ts
|
||||
plugin.addClientSlashCommand({
|
||||
client.addSlashCommand({
|
||||
name: "review",
|
||||
description: "Run the review bot",
|
||||
argumentHint: "[scope]",
|
||||
@@ -639,9 +687,9 @@ plugin.addClientSlashCommand({
|
||||
| `onSubmit` | Yes | Client callback for the matching context. |
|
||||
|
||||
`onSubmit` receives the matching Command Center callback context plus `args`. For `/review src`,
|
||||
`args` is `"src"`; Paseo trims only the remainder's leading and trailing whitespace. Paseo owns the
|
||||
autocomplete row, input clearing, and error toast. A handled command is never sent to the agent.
|
||||
The compiler removes this registration from the plugin's server bundle.
|
||||
`args` is `"src"`; Paseo trims only the remainder's leading and trailing whitespace and leaves
|
||||
parsing to the plugin. Paseo owns the autocomplete row, input clearing, and the error toast. It
|
||||
does not wait for `onSubmit` or show a pending state; use a composer pill or panel for that.
|
||||
|
||||
Precedence is built-in client commands, plugin commands, then provider commands. A lower-precedence
|
||||
collision is omitted. Built-in aliases also reserve their names. The first plugin in stable catalog
|
||||
@@ -649,18 +697,8 @@ order wins a collision between plugins. Commands do not run while the composer h
|
||||
|
||||
## Composer pills
|
||||
|
||||
Register a headless client entrypoint from `index.ts`:
|
||||
|
||||
```ts
|
||||
import { contributeClient } from "./review.client";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.addClientSide(contributeClient);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
The client entrypoint owns pill creation and removal:
|
||||
The client entry owns pill creation and removal. This can live directly in `index.client.tsx` or in
|
||||
a function it imports from `client/`:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
@@ -683,7 +721,7 @@ function ReviewPill({ theme, agentId }: PluginComposerPillProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
const pills = new Map<string, () => void>();
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
|
||||
@@ -722,10 +760,10 @@ export function contributeClient(client: PluginClientContext) {
|
||||
| `Component` | Yes | React Native component rendering the pill's icon and text. |
|
||||
| `onPress` | Yes | Client-side callback. |
|
||||
|
||||
`addClientSide` runs once per plugin installation in each connected app. Its context exposes
|
||||
`paseo`, typed `rpc`, `openSurface`, explicit-context `openPanel`, and `addComposerPill`.
|
||||
The client entry runs once per plugin installation in each connected app. Its context exposes
|
||||
`paseo`, typed `rpc`, `openSurface`, explicit-context `openPanel`, and every client registration.
|
||||
`addComposerPill` returns an idempotent removal function. Paseo also removes every outstanding pill
|
||||
when the client entrypoint, plugin installation, or host connection is torn down.
|
||||
when the plugin installation or host connection is torn down.
|
||||
|
||||
Paseo owns the pressable, shared pill chrome, pending state, error reporting, and track-bar
|
||||
placement. The component receives `theme`, `host`, `layout`, `workspaceId`, and `agentId`. Read
|
||||
@@ -776,10 +814,10 @@ Use plugin RPC only for work that is not a normal Paseo operation: reading a ven
|
||||
|
||||
Define one contract with Zod, handle it in the subprocess, and call it from the surface:
|
||||
|
||||
`greeting.shared.ts`:
|
||||
`shared/greeting.ts`:
|
||||
|
||||
```ts
|
||||
import { defineRpc } from "@getpaseo/plugin/server";
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
|
||||
export const greeting = defineRpc({
|
||||
@@ -789,11 +827,11 @@ export const greeting = defineRpc({
|
||||
});
|
||||
```
|
||||
|
||||
`greeting.client.tsx`:
|
||||
`client/greeting.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useRpc } from "@getpaseo/plugin";
|
||||
import { greeting } from "./greeting.shared";
|
||||
import { greeting } from "../shared/greeting";
|
||||
|
||||
export function GreetingButton() {
|
||||
const createGreeting = useRpc(greeting);
|
||||
@@ -802,28 +840,38 @@ export function GreetingButton() {
|
||||
}
|
||||
```
|
||||
|
||||
`greeting.server.ts`:
|
||||
`server/greeting.ts`:
|
||||
|
||||
```ts
|
||||
import type { output as ZodOutput } from "zod";
|
||||
import { greeting } from "./greeting.shared";
|
||||
import type { RpcInput } from "@getpaseo/plugin";
|
||||
import { greeting } from "../shared/greeting";
|
||||
|
||||
export function createGreeting({ name }: ZodOutput<typeof greeting.input>) {
|
||||
export function createGreeting({ name }: RpcInput<typeof greeting>) {
|
||||
return { message: `Hello, ${name}` };
|
||||
}
|
||||
```
|
||||
|
||||
`index.ts`:
|
||||
`index.client.tsx`:
|
||||
|
||||
```ts
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { GreetingButton } from "./greeting.client";
|
||||
import { createGreeting } from "./greeting.server";
|
||||
import { greeting } from "./greeting.shared";
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { GreetingButton } from "./client/greeting";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(greeting, createGreeting);
|
||||
plugin.addSurface("main", GreetingButton);
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addSurface("main", GreetingButton);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
`index.server.ts`:
|
||||
|
||||
```ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { createGreeting } from "./server/greeting";
|
||||
import { greeting } from "./shared/greeting";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.handle(greeting, createGreeting);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
@@ -873,10 +921,10 @@ daemon log persists it.
|
||||
|
||||
An attachment source searches external resources and returns a stable text snapshot for an agent prompt. Keep credentials and vendor calls in the backend handler.
|
||||
|
||||
`issues.shared.ts`:
|
||||
`shared/issues.ts`:
|
||||
|
||||
```ts
|
||||
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin/server";
|
||||
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
|
||||
export const searchIssues = defineRpc({
|
||||
@@ -907,27 +955,38 @@ export const issues = defineAttachmentSource({
|
||||
});
|
||||
```
|
||||
|
||||
`issues.server.ts`:
|
||||
`server/issues.ts`:
|
||||
|
||||
```ts
|
||||
import type { output as ZodOutput } from "zod";
|
||||
import { searchIssues } from "./issues.shared";
|
||||
import type { RpcInput } from "@getpaseo/plugin";
|
||||
import { searchIssues } from "../shared/issues";
|
||||
|
||||
export function search({ query }: ZodOutput<typeof searchIssues.input>) {
|
||||
export function search({ query }: RpcInput<typeof searchIssues>) {
|
||||
return searchAcmeIssues(query);
|
||||
}
|
||||
```
|
||||
|
||||
`index.ts`:
|
||||
`index.client.tsx`:
|
||||
|
||||
```ts
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { search } from "./issues.server";
|
||||
import { issues, searchIssues } from "./issues.shared";
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { issues } from "./shared/issues";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(searchIssues, search);
|
||||
plugin.addAttachmentSource(issues);
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addAttachmentSource(issues);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
`index.server.ts`:
|
||||
|
||||
```ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { search } from "./server/issues";
|
||||
import { searchIssues } from "./shared/issues";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.handle(searchIssues, search);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
@@ -1001,17 +1060,22 @@ Run `npm run typecheck` before install or reload. Never edit the daemon config d
|
||||
|
||||
The daemon-wide **Enable plugins** switch lives under **Settings → Plugins**. A configured plugin remains `disabled` until that switch and the plugin's own enabled state are both on.
|
||||
|
||||
The switch is the root `pluginsEnabled` field in `config.json`. After changing it, run `paseo reload --json`. Enabling starts every configured plugin whose own `enabled` value is not `false`; disabling tears down all plugins. No daemon restart is required. Manual edits to plugin source entries are not reloaded—use the plugin lifecycle commands for those.
|
||||
The switch is the root `pluginsEnabled` field in `config.json`. After changing it, run `paseo reload --json`. Enabling starts every configured plugin whose own `enabled` value is not `false`; disabling tears down all plugins. No daemon restart is required. Manual edits to plugin source entries are not reloaded; use the plugin lifecycle commands for those.
|
||||
|
||||
## Load failures
|
||||
|
||||
Use `paseo plugin ls` to read the current status and error.
|
||||
|
||||
| Symptom | Check |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Sidebar item is missing | The plugin is `running`, the item references an existing surface, the icon name is valid, and the client is on the installation's host. |
|
||||
| Client module is unavailable | Import only the host-provided client modules listed above. |
|
||||
| RPC rejects | Check both Zod schemas and the daemon-side handler error. |
|
||||
| Edited code does not appear | Run `npm run typecheck`, then `paseo plugin reload <id>`. |
|
||||
| Reload fails | Read `paseo plugin ls` and `paseo plugin logs <id>`, fix the source error, then reload; Paseo does not restore the previous bundle. |
|
||||
| Plugin exits unexpectedly | Read `paseo plugin logs <id>` for retained initialization, cleanup, stderr, and final crash output. |
|
||||
| Symptom | Check |
|
||||
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Plugin entry split is required` | The directory has only an `index.ts` entry. Follow the [migration guide](/docs/plugins/migration). |
|
||||
| `Plugin entry points are missing` | Neither `index.client.tsx` nor `index.server.ts` exists with that exact name. |
|
||||
| `server-only module cannot be imported into the plugin client bundle` | Client code imports `server/` or a `*.server.*` file. Move the work behind an RPC and import its contract from `shared/`. |
|
||||
| `client-only module cannot be imported into the plugin server bundle` | Server code imports `client/` or a `*.client.*` file. Register that contribution from `index.client.tsx` instead. |
|
||||
| `Node module cannot be imported into the plugin client bundle` | Client code imports `node:*`. Move the operation to `server/` and call it through an RPC. |
|
||||
| Sidebar item is missing | The plugin is `running`, the item references an existing surface, the icon name is valid, and the client is on the installation's host. |
|
||||
| Client module is unavailable | Import only the host-provided client modules listed above. |
|
||||
| RPC rejects | Check both Zod schemas and the daemon-side handler error. |
|
||||
| Edited code does not appear | Run `npm run typecheck`, then `paseo plugin reload <id>`. |
|
||||
| Reload fails | Read `paseo plugin ls` and `paseo plugin logs <id>`, fix the source error, then reload; Paseo does not restore the previous bundle. |
|
||||
| Plugin exits unexpectedly | Read `paseo plugin logs <id>` for retained initialization, cleanup, stderr, and final crash output. |
|
||||
|
||||
+147
-62
@@ -29,13 +29,13 @@ Pick the contribution that matches the request. Each row names the registration,
|
||||
| Sidebar surface | `addSurface` + `addSidebarItem` | A full screen of plugin UI reachable from the sidebar | reference.md → Surfaces and sidebar items; `plugin-examples/local-plugin` |
|
||||
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
|
||||
| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items |
|
||||
| Client slash command | `addClientSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Client slash commands |
|
||||
| Composer pill | `addClientSide` + `client.addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
|
||||
| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Client slash commands |
|
||||
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
|
||||
| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` |
|
||||
| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon |
|
||||
| Attachment source | `addAttachmentSource` + `plugin.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
|
||||
| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
|
||||
| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` |
|
||||
| Plugin RPC | `defineRpc` + `plugin.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
|
||||
| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
|
||||
| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
|
||||
|
||||
## Create the project
|
||||
@@ -53,10 +53,13 @@ The generated project contains:
|
||||
```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 manifest supplies the default install ID:
|
||||
@@ -65,28 +68,48 @@ The manifest supplies the default install ID:
|
||||
{ "id": "my-plugin" }
|
||||
```
|
||||
|
||||
Keep `index.ts` to contribution wiring. Runtime code lives behind filename boundaries, and the compiler strips client registrations from the server bundle and server registrations from the client bundle:
|
||||
Each runtime has its own optional entry. A plugin must have at least one. Both entries accept
|
||||
`.ts` or `.tsx`; use `.tsx` when an entry imports components.
|
||||
|
||||
| Suffix | Owns |
|
||||
| -------------- | --------------------------------------------------------------- |
|
||||
| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, callbacks |
|
||||
| `*.server.ts` | Node APIs, filesystem and process access, credentials, handlers |
|
||||
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes |
|
||||
| Path | Runtime |
|
||||
| -------------------------------- | ----------------- |
|
||||
| `index.client.tsx` and `client/` | App |
|
||||
| `index.server.ts` and `server/` | Daemon subprocess |
|
||||
| `shared/` | Both |
|
||||
|
||||
Importing a `*.server` module from a client module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls belong in `*.client.tsx`.
|
||||
Do not put any other code modules in the plugin root.
|
||||
|
||||
Default-export one contribution function. It must return cleanup, even when there is nothing to clean:
|
||||
A client import of `server/`, a server import of `client/`, and every `node:` import reachable from
|
||||
client code is a compile error. A relative import to another code file in the plugin root is also a
|
||||
compile error; move it into `client/`, `server/`, or `shared/`. Shared modules contain Zod contracts
|
||||
and plain values; they do not import Node or React Native runtime APIs.
|
||||
|
||||
Default-export one contribution function from each entry and return cleanup:
|
||||
|
||||
```tsx
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
// index.client.tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
// Register contributions here.
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
// Register components and client callbacks here.
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
Cleanup can be async. Use it for timers, watchers, sockets, and other resources created by plugin code. Paseo removes registrations, unmounts surfaces, rejects pending RPCs, closes the plugin session, and stops the subprocess when the plugin stops.
|
||||
```ts
|
||||
// index.server.ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
// Register daemon-side RPC handlers here.
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
Cleanup can be async. Use it for timers, watchers, sockets, subscriptions, and other resources
|
||||
created by plugin code. Every client `add*` method returns an idempotent remover. Paseo calls the
|
||||
entry cleanup first, removes registrations that remain, rejects pending RPCs, closes the plugin
|
||||
session, and stops the subprocess when the plugin stops.
|
||||
|
||||
## Add a workspace panel
|
||||
|
||||
@@ -95,7 +118,11 @@ mobile, and Paseo has multiple themes. Every `Text` must take its color from `th
|
||||
Use `layout.compact` for padding and stacking. Unstyled text is black and fails in dark themes.
|
||||
|
||||
```tsx
|
||||
import { type PluginContext, type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin";
|
||||
import {
|
||||
type PluginClientContext,
|
||||
type PluginWorkspacePanelProps,
|
||||
useWorkspace,
|
||||
} from "@getpaseo/plugin";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
@@ -120,15 +147,15 @@ function Overview({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.addWorkspacePanel({
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addWorkspacePanel({
|
||||
id: "overview",
|
||||
title: "Workspace overview",
|
||||
icon: "PanelsTopLeft",
|
||||
context: "workspace",
|
||||
Component: Overview,
|
||||
});
|
||||
plugin.addCommandCenterItem({
|
||||
client.addCommandCenterItem({
|
||||
id: "open-overview",
|
||||
title: "Open workspace overview",
|
||||
icon: "PanelsTopLeft",
|
||||
@@ -151,7 +178,7 @@ the active workspace or agent. Command callbacks receive the selected host's `pa
|
||||
Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item. Color text from `theme.colors` and pad from `layout.compact`.
|
||||
|
||||
```tsx
|
||||
import type { PluginContext, PluginSurfaceProps } from "@getpaseo/plugin";
|
||||
import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
|
||||
@@ -186,9 +213,9 @@ function Counter({ theme, layout }: PluginSurfaceProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.addSurface("main", Counter);
|
||||
plugin.addSidebarItem({
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addSurface("main", Counter);
|
||||
client.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Counter",
|
||||
icon: "ListPlus",
|
||||
@@ -200,13 +227,33 @@ export default function contribute(plugin: PluginContext) {
|
||||
|
||||
Icons are Lucide icon names. `theme` is a typed `PluginTheme` on every surface and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client.
|
||||
|
||||
Client code may import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@getpaseo/plugin`, `@getpaseo/plugin/react-native`, and `@getpaseo/plugin/server`. Install dependencies locally for typechecking; Paseo supplies these runtime modules. JSX compiles with the automatic runtime, so no `React` import is needed for JSX.
|
||||
Client code may import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@getpaseo/plugin`, and `@getpaseo/plugin/react-native`. Install dependencies locally for typechecking; Paseo supplies these runtime modules. JSX compiles with the automatic runtime, so no `React` import is needed for JSX. Importing a `node:` module from client code is a compile error.
|
||||
|
||||
| Module | Use it for |
|
||||
| ------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `@getpaseo/plugin` | contribution contracts, `usePaseo`, `useRpc`, `useWorkspace`, `useAgent` |
|
||||
| `@getpaseo/plugin/react-native` | Paseo UI: `Icon`, `Modal`, `useToast`, `useRevealedText` |
|
||||
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, handler types |
|
||||
| Module | Use it for |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, `usePaseo`, `useRpc`, and data hooks |
|
||||
| `@getpaseo/plugin/react-native` | Paseo UI: `Icon`, `Modal`, `useToast`, `useRevealedText` |
|
||||
| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` |
|
||||
|
||||
## Works on mobile
|
||||
|
||||
Before reporting a plugin done:
|
||||
|
||||
- Use React Native primitives only: `View`, `Text`, `Pressable`, `ScrollView`, and `TextInput`.
|
||||
- Do not use HTML elements, `className`, CSS strings, or `onClick`.
|
||||
- Do not put `"DOM"` in `tsconfig.json` or use `/// <reference lib="dom" />`. Put DOM globals only in
|
||||
`client/web.ts`, declare only what that module uses, gate every export on `Platform.OS === "web"`,
|
||||
and provide the native alternative or a no-op.
|
||||
- Take colors from `theme.colors`.
|
||||
- Check the compact layout.
|
||||
|
||||
Run this audit on `client/`:
|
||||
|
||||
```bash
|
||||
rg -n "document\.|window\.|localStorage|navigator\.|<[a-z]+[ >]|className=|onClick=" client/
|
||||
```
|
||||
|
||||
A hit outside `client/web.ts` is a bug.
|
||||
|
||||
## Choose the correct API
|
||||
|
||||
@@ -247,12 +294,12 @@ The API covers workspaces, agents, providers, and daemon config. It omits connec
|
||||
|
||||
### Add daemon-side behavior
|
||||
|
||||
Define one Zod contract, register its subprocess handler, and call it with `useRpc()`:
|
||||
Define one Zod contract in `shared/`, register its subprocess handler in `index.server.ts`, and
|
||||
call it from client code with `useRpc()`:
|
||||
|
||||
```tsx
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { useRpc } from "@getpaseo/plugin";
|
||||
import { defineRpc } from "@getpaseo/plugin/server";
|
||||
```ts
|
||||
// shared/greeting.ts
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
|
||||
const greeting = defineRpc({
|
||||
@@ -260,21 +307,40 @@ const greeting = defineRpc({
|
||||
input: z.object({ name: z.string() }),
|
||||
output: z.object({ message: z.string() }),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/greeting.ts
|
||||
import type { RpcInput } from "@getpaseo/plugin";
|
||||
import { greeting } from "../shared/greeting";
|
||||
|
||||
export async function createGreeting({ name }: RpcInput<typeof greeting>) {
|
||||
return { message: `Hello, ${name}!` };
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// index.server.ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { createGreeting } from "./server/greeting";
|
||||
import { greeting } from "./shared/greeting";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.handle(greeting, createGreeting);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// client/greeting.tsx
|
||||
import { useRpc } from "@getpaseo/plugin";
|
||||
import { greeting } from "../shared/greeting";
|
||||
|
||||
function Greeting() {
|
||||
const createGreeting = useRpc(greeting);
|
||||
// Use createGreeting({ name: "Ada" }) in a query, mutation, or event.
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(greeting, async ({ name }, { paseo }) => {
|
||||
const { config } = await paseo.config.get();
|
||||
return { message: `${name}: plugins are ${config.pluginsEnabled ? "on" : "off"}` };
|
||||
});
|
||||
plugin.addSurface("main", Greeting);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
Inputs and outputs are validated on both sides. Backend handlers receive the same `PaseoApi` as `{ paseo }`; their IPC-backed daemon session lives exactly as long as the subprocess. Backend code can use Node APIs and installed dependencies. Keep credentials, filesystem access, shell commands, and vendor API calls in the handler rather than the client surface.
|
||||
@@ -301,11 +367,12 @@ daemon log. Never log credentials or other secrets.
|
||||
|
||||
## Add a composer attachment source
|
||||
|
||||
Define a search RPC and register a declarative source:
|
||||
Define a search RPC and declarative source in `shared/`, handle it on the server, and register it on
|
||||
the client:
|
||||
|
||||
```tsx
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin/server";
|
||||
```ts
|
||||
// shared/issues.ts
|
||||
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
|
||||
const searchIssues = defineRpc({
|
||||
@@ -334,10 +401,26 @@ const issues = defineAttachmentSource({
|
||||
searchPlaceholder: "Search by identifier or title",
|
||||
search: searchIssues,
|
||||
});
|
||||
```
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
|
||||
plugin.addAttachmentSource(issues);
|
||||
```ts
|
||||
// index.server.ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { searchIssues } from "./shared/issues";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
|
||||
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 () => {};
|
||||
}
|
||||
```
|
||||
@@ -349,7 +432,7 @@ Return complete text snapshots. Paseo owns the composer menu, picker, pills, dra
|
||||
A slash command runs plugin code in the app when the user submits `/name args`. Nothing is sent to the agent. `args` is the raw text after the command name, trimmed; parse it in the plugin.
|
||||
|
||||
```ts
|
||||
plugin.addClientSlashCommand({
|
||||
client.addSlashCommand({
|
||||
name: "review",
|
||||
description: "Run the review bot",
|
||||
argumentHint: "[scope]",
|
||||
@@ -361,11 +444,11 @@ plugin.addClientSlashCommand({
|
||||
});
|
||||
```
|
||||
|
||||
The callback receives the same context as the matching Command Center item plus `args`. Paseo owns the autocomplete row, input clearing, and the error toast; put pending UI in a pill or panel. Precedence is built-in client commands, then plugin commands, then provider commands; a lower-precedence collision is dropped. Commands do not run while the composer has attachments. Server-side slash commands do not exist yet; the name is qualified so they can.
|
||||
The callback receives the same context as the matching Command Center item plus `args`. Paseo owns the autocomplete row, input clearing, and the error toast; put pending UI in a pill or panel. Precedence is built-in client commands, then plugin commands, then provider commands; a lower-precedence collision is dropped. Commands do not run while the composer has attachments. Server-side slash commands do not exist.
|
||||
|
||||
## Add a composer pill
|
||||
|
||||
A pill is a per-agent button in the composer track bar next to Tasks and Subagents. Register a headless client entrypoint with `addClientSide`, then add and remove pills from that lifecycle. `addComposerPill` exists only on `PluginClientContext`.
|
||||
A pill is a per-agent button in the composer track bar next to Tasks and Subagents. Add and remove pills from the client entry lifecycle. `addComposerPill` exists on `PluginClientContext`.
|
||||
|
||||
```tsx
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
@@ -395,14 +478,14 @@ export function contributeClient(client: PluginClientContext) {
|
||||
}
|
||||
```
|
||||
|
||||
Wire it with `plugin.addClientSide(contributeClient)`. The component owns its icon and text; Paseo owns the pressable, chrome, pending state, error reporting, and placement. Removal functions are idempotent, and Paseo removes every pill when the plugin, client entrypoint, or host connection is torn down.
|
||||
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry. The component owns its icon and text; Paseo owns the pressable, chrome, pending state, error reporting, and placement. Removal functions are idempotent, and Paseo removes every pill when the plugin, client entrypoint, or host connection is torn down.
|
||||
|
||||
## Transform and render timeline items
|
||||
|
||||
Timeline transformers and renderers are client contributions. A transformer selects one built-in `AgentTimelineItem.type`, inspects the item, and returns zero or more versioned plugin items. `undefined` keeps the source item, `items` replaces it, `[]` removes it. A renderer draws one `kind` and `version` after validating `data` with its Zod schema.
|
||||
|
||||
```ts
|
||||
plugin.addTimelineTransformer({
|
||||
client.addTimelineTransformer({
|
||||
id: "inline-thinking",
|
||||
query: { itemType: "reasoning" },
|
||||
transform: ({ item, phase }) => ({
|
||||
@@ -411,7 +494,7 @@ plugin.addTimelineTransformer({
|
||||
],
|
||||
}),
|
||||
});
|
||||
plugin.addTimelineRenderer({
|
||||
client.addTimelineRenderer({
|
||||
kind: "inline-thinking",
|
||||
version: 1,
|
||||
schema: z.object({ text: z.string(), phase: z.enum(["streaming", "complete"]) }),
|
||||
@@ -426,7 +509,7 @@ Transformers run while the render model is built, on fetched history and on ever
|
||||
A server handler can push a plugin-owned row into any agent timeline. The same renderer registration draws it.
|
||||
|
||||
```ts
|
||||
plugin.handle(publishReview, async ({ agentId, verdict }, { paseo }) => {
|
||||
server.handle(publishReview, async ({ agentId, verdict }, { paseo }) => {
|
||||
await paseo.agents.ref(agentId).timeline.append({
|
||||
type: "plugin",
|
||||
id: "review",
|
||||
@@ -445,7 +528,7 @@ The daemon stamps `pluginId` from the plugin session, so only plugin code can ca
|
||||
`addTheme` takes a small light or dark palette; Paseo expands it into the full token set. Every color is a hex string.
|
||||
|
||||
```ts
|
||||
plugin.addTheme({
|
||||
client.addTheme({
|
||||
id: "mocha",
|
||||
name: "Catppuccin Mocha",
|
||||
appearance: "dark",
|
||||
@@ -462,7 +545,7 @@ plugin.addTheme({
|
||||
});
|
||||
```
|
||||
|
||||
It appears under Settings → Appearance. A daemon that predates `addTheme` fails to start the plugin with `plugin.addTheme is not a function`; update the host. See `plugin-examples/catppuccin`.
|
||||
It appears under Settings → Appearance. A client that predates `addTheme` cannot evaluate the entry and reports `client.addTheme is not a function`; update the client. See `plugin-examples/catppuccin`.
|
||||
|
||||
## Hosts and trust
|
||||
|
||||
@@ -515,6 +598,8 @@ Use `--host <url>` when managing a daemon other than the CLI default. A Git sour
|
||||
|
||||
Do not restart the daemon to load source changes. Restarting it can kill the agent performing the work.
|
||||
|
||||
For an old mixed entry, follow the standalone [runtime-entry migration guide](https://paseo.sh/docs/plugins/migration.md) mechanically.
|
||||
|
||||
## Verify the outcome
|
||||
|
||||
After a change:
|
||||
|
||||
Reference in New Issue
Block a user