mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
Declare and enforce plugin Paseo version requirements (#4430)
* feat(plugins): enforce declared Paseo version requirements Treat manifests without a Paseo requirement as pre-0.8 plugins so the first breaking plugin release offers an explicit migration path. Check the daemon and app independently before plugin execution, and preserve installed Git revisions when updates are incompatible. * fix(nix): refresh dependency hash for plugin requirements * refactor(plugins): validate test manifest inputs and name compatibility input
This commit is contained in:
+8
-2
@@ -41,7 +41,7 @@ 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 optional entry per runtime, runtime-owned
|
||||
The directory contains a manifest declaring identity and Paseo requirements, one optional entry per runtime, runtime-owned
|
||||
directories, and local typechecking support. At least one entry is required.
|
||||
|
||||
```text
|
||||
@@ -62,10 +62,15 @@ runtime modules, so consumers do not install these packages when adding the plug
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin"
|
||||
"id": "my-plugin",
|
||||
"requirements": { "paseo": ">=0.8.0" }
|
||||
}
|
||||
```
|
||||
|
||||
Declare the supported Paseo range and keep it current when adopting newer APIs. See the
|
||||
[requirements contract](../public-docs/plugins/v0.8/reference.md#requirements), including legacy
|
||||
manifests and prerelease matching.
|
||||
|
||||
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
|
||||
`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the
|
||||
runtime does not compare the two IDs. The same directory can be installed under several config
|
||||
@@ -111,6 +116,7 @@ step:
|
||||
```json
|
||||
{
|
||||
"id": "review",
|
||||
"requirements": { "paseo": ">=0.8.0" },
|
||||
"build": [
|
||||
["npm", "ci"],
|
||||
["npm", "run", "build"]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-Vw6bgh+gPdVxySdbawKawr9qZT8QEEo2+okdpuKceMA=
|
||||
sha256-1MBzb+r/t92G9uR/jBuc3oNEfEloHeRg9uUgzSHS4UM=
|
||||
|
||||
Generated
+21
@@ -13059,6 +13059,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
@@ -38731,15 +38738,29 @@
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/semver": "^7.8.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6",
|
||||
"zod-aot": "0.20.4"
|
||||
}
|
||||
},
|
||||
"packages/protocol/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.7.2",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -118,7 +119,10 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
|
||||
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, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -120,7 +121,10 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
|
||||
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, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "e2e-plugin", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
@@ -95,11 +95,10 @@ const test = base.extend<{ modalExample: Page }>({
|
||||
modalExample: async ({ page, context }, provide) => {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("modal-ui");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(
|
||||
path.resolve(__dirname, "../../../../plugin-examples/modal-ui"),
|
||||
);
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await provide(page);
|
||||
} finally {
|
||||
@@ -108,6 +107,7 @@ const test = base.extend<{ modalExample: Page }>({
|
||||
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await example.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
test,
|
||||
openRequirementHost,
|
||||
expectAppMismatch,
|
||||
correctRequirementAndReload,
|
||||
rejectDaemonMismatch,
|
||||
} from "../support/helpers/plugin-requirements";
|
||||
|
||||
test("shows which runtime is incompatible and recovers after the requirement is corrected", async ({
|
||||
page,
|
||||
requirementHost,
|
||||
}) => {
|
||||
await openRequirementHost(page, requirementHost);
|
||||
await expectAppMismatch(page);
|
||||
await correctRequirementAndReload(page, requirementHost.directory);
|
||||
await rejectDaemonMismatch(page, requirementHost.directory);
|
||||
await correctRequirementAndReload(page, requirementHost.directory);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -53,7 +54,10 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-e2e-"));
|
||||
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, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -199,7 +200,10 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
repoPrefix: "plugin-panel-secondary-",
|
||||
port: secondaryDaemon.port,
|
||||
});
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writePluginSources(directory, {
|
||||
workspaceId: primary.workspaceId,
|
||||
agentId: "missing-agent",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import appPackage from "../../../package.json";
|
||||
|
||||
export const pluginRequirements = { paseo: `>=${appPackage.version}` };
|
||||
|
||||
export async function copyPluginExample(name: string) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-example-"));
|
||||
try {
|
||||
await cp(path.resolve(__dirname, "../../../../../plugin-examples", name), directory, {
|
||||
recursive: true,
|
||||
});
|
||||
const manifestPath = path.join(directory, "paseo-plugin.json");
|
||||
const manifest = z
|
||||
.record(z.string(), z.unknown())
|
||||
.parse(JSON.parse(await readFile(manifestPath, "utf8")));
|
||||
// Exercise example UI against the checkout runtime before its release version is bumped.
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify({ ...manifest, requirements: pluginRequirements }),
|
||||
);
|
||||
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
||||
} catch (error) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { spawnTsx, killProcessTree } from "./spawn-node";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { addConnectedHostAndReload } from "./hosts";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openHostSection, selectSettingsHost } from "./settings";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
export const test = base.extend<{
|
||||
requirementHost: { serverId: string; port: number; directory: string; client: DaemonClient };
|
||||
}>({
|
||||
requirementHost: async ({ e2eWorker }, provide) => {
|
||||
void e2eWorker;
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-requirement-ui-"));
|
||||
await writeRequirements(directory, "^99.0.0");
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.ts"),
|
||||
`export default function(client) {
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const daemon = spawnTsx(
|
||||
path.resolve(__dirname, "../../../../server/src/server/test-utils/versioned-daemon.ts"),
|
||||
["99.0.0"],
|
||||
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
|
||||
);
|
||||
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
|
||||
const timeout = setTimeout(
|
||||
() => ready.reject(new Error("Versioned daemon startup timed out")),
|
||||
30_000,
|
||||
);
|
||||
let output = "";
|
||||
daemon.stdout?.on("data", (data) => {
|
||||
output += data;
|
||||
});
|
||||
daemon.stderr?.on("data", (data) => {
|
||||
output += data;
|
||||
});
|
||||
daemon.once("error", ready.reject);
|
||||
daemon.once("exit", () => ready.reject(new Error(`Versioned daemon exited: ${output}`)));
|
||||
daemon.once("message", (message) => {
|
||||
if (
|
||||
typeof message !== "object" ||
|
||||
message === null ||
|
||||
!("port" in message) ||
|
||||
typeof message.port !== "number" ||
|
||||
!("serverId" in message) ||
|
||||
typeof message.serverId !== "string"
|
||||
) {
|
||||
ready.reject(new Error("Invalid versioned daemon startup message"));
|
||||
return;
|
||||
}
|
||||
ready.resolve({ port: message.port, serverId: message.serverId });
|
||||
});
|
||||
try {
|
||||
const host = await ready.promise;
|
||||
clearTimeout(timeout);
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
port: host.port,
|
||||
clientIdPrefix: "requirements",
|
||||
});
|
||||
try {
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await provide({ ...host, directory, client });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
await killProcessTree(daemon);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function writeRequirements(directory: string, paseo: string) {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "requirements-example", requirements: { paseo } }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openRequirementHost(page: Page, host: { serverId: string; port: number }) {
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, { ...host, label: "Plugin requirements" });
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, host.serverId);
|
||||
await openHostSection(page, host.serverId, "plugins");
|
||||
}
|
||||
|
||||
export async function expectAppMismatch(page: Page) {
|
||||
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/Plugin "requirements-example" requires Paseo \^99.0.0. Your app is/),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function correctRequirementAndReload(page: Page, directory: string) {
|
||||
await writeRequirements(directory, pluginRequirements.paseo);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByLabel("requirements-example running", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Your app is/)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function rejectDaemonMismatch(page: Page, directory: string) {
|
||||
await writeRequirements(directory, ">=100.0.0");
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByLabel("requirements-example failed", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Your daemon is 99.0.0/).first()).toBeVisible();
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import path from "node:path";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
|
||||
const directory = path.resolve(__dirname, "../../../../../plugin-examples/settings");
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
@@ -14,15 +13,17 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
void e2eWorker;
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("settings");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await provide(client);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings.png") });
|
||||
} finally {
|
||||
await client.removePlugin("settings-example");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
}
|
||||
},
|
||||
{ auto: true },
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { PluginRegistry } from "./registry";
|
||||
|
||||
const client = new DaemonClient({ url: "ws://unused.test", clientId: "plugin-requirements-test" });
|
||||
const registries: PluginRegistry[] = [];
|
||||
function registry(version: string) {
|
||||
let starts = 0;
|
||||
let cleanups = 0;
|
||||
const result = new PluginRegistry({
|
||||
version,
|
||||
createRuntime() {
|
||||
starts++;
|
||||
return {
|
||||
paseo: createPaseoApi(client),
|
||||
rpc: async () => {
|
||||
throw new Error("No RPC in this plugin");
|
||||
},
|
||||
openSettings() {
|
||||
cleanups++;
|
||||
},
|
||||
openSurface() {},
|
||||
openPanel() {},
|
||||
addComposerPill: () => () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
registries.push(result);
|
||||
return { result, starts: () => starts, cleanups: () => cleanups };
|
||||
}
|
||||
const clientBundle =
|
||||
'(function() { return { default: function(client) { return () => client.openSettings("cleanup"); } }; })';
|
||||
afterEach(() => {
|
||||
for (const value of registries.splice(0)) value.removeHost("host");
|
||||
});
|
||||
|
||||
it("checks the app version before creating a runtime or evaluating plugin code", () => {
|
||||
const { result, starts } = registry("0.8.0");
|
||||
result.installCatalog(
|
||||
"host",
|
||||
[
|
||||
{
|
||||
id: "example",
|
||||
requirements: { paseo: ">=0.9.0" },
|
||||
clientBundle: "throw new Error('executed')",
|
||||
},
|
||||
],
|
||||
{ client },
|
||||
);
|
||||
expect(starts()).toBe(0);
|
||||
expect(result.getSnapshot()).toEqual([]);
|
||||
expect(result.getEvaluationError("host", "example")).toBe(
|
||||
'Plugin "example" requires Paseo >=0.9.0. Your app is 0.8.0. Use a compatible plugin version or update the app.',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects catalogs without requirements from pre-0.8 daemons", () => {
|
||||
const { result, starts } = registry("0.8.0");
|
||||
result.installCatalog("host", [{ id: "example", clientBundle }], { client });
|
||||
expect(starts()).toBe(0);
|
||||
expect(result.getEvaluationError("host", "example")).toContain(
|
||||
"https://paseo.sh/docs/plugins/v0.8/migration",
|
||||
);
|
||||
});
|
||||
|
||||
it("unloads on a requirement-only edit and recovers after correction", () => {
|
||||
const { result, starts, cleanups } = registry("0.8.0");
|
||||
const install = (paseo: string) =>
|
||||
result.installCatalog("host", [{ id: "example", clientBundle, requirements: { paseo } }], {
|
||||
client,
|
||||
});
|
||||
install("^0.8.0");
|
||||
expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]);
|
||||
install("^0.8.0");
|
||||
expect(starts()).toBe(1);
|
||||
install(">=0.9.0");
|
||||
expect(cleanups()).toBe(1);
|
||||
expect(starts()).toBe(1);
|
||||
expect(result.getSnapshot()).toEqual([]);
|
||||
expect(result.getEvaluationError("host", "example")).toContain("requires Paseo >=0.9.0");
|
||||
install(">=0.8.0");
|
||||
expect(starts()).toBe(2);
|
||||
expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]);
|
||||
expect(result.getEvaluationError("host", "example")).toBeUndefined();
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import appPackage from "../../package.json";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { pluginRegistry as registry } from "./registry";
|
||||
@@ -25,7 +26,11 @@ const pluginRegistry = {
|
||||
catalog: Parameters<typeof registry.installCatalog>[1],
|
||||
options: { replacePluginId?: string } = {},
|
||||
) {
|
||||
return registry.installCatalog(serverId, catalog, { ...options, client: daemonClient });
|
||||
return registry.installCatalog(
|
||||
serverId,
|
||||
catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })),
|
||||
{ ...options, client: daemonClient },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { createPluginClientRuntime } from "./client-runtime";
|
||||
import { runPluginClientBundle } from "./evaluate";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
interface CatalogPlugin {
|
||||
id: string;
|
||||
clientBundle: string;
|
||||
}
|
||||
type CatalogPlugin = Awaited<ReturnType<DaemonClient["getPluginCatalog"]>>[number];
|
||||
|
||||
class PluginRegistry {
|
||||
export class PluginRegistry {
|
||||
private readonly byHost = new Map<string, InstalledPlugin[]>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private snapshot: InstalledPlugin[] = [];
|
||||
private readonly disposed = new WeakSet<InstalledPlugin>();
|
||||
private readonly evaluationErrors = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: {
|
||||
version: string | null;
|
||||
createRuntime: typeof createPluginClientRuntime;
|
||||
},
|
||||
) {}
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
@@ -45,7 +51,8 @@ class PluginRegistry {
|
||||
(plugin) =>
|
||||
plugin.id !== options.replacePluginId &&
|
||||
plugin.id === entry.id &&
|
||||
plugin.clientBundle === entry.clientBundle,
|
||||
plugin.clientBundle === entry.clientBundle &&
|
||||
plugin.requirements?.paseo === entry.requirements?.paseo,
|
||||
);
|
||||
return existing ? [existing] : [];
|
||||
});
|
||||
@@ -59,6 +66,7 @@ class PluginRegistry {
|
||||
const key = `${serverId}/${entry.id}`;
|
||||
try {
|
||||
if (!entry.clientBundle) return [];
|
||||
assertPluginCompatibility({ ...entry, version: this.dependencies.version, runtime: "app" });
|
||||
const existing = preserved.find(
|
||||
(plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle,
|
||||
);
|
||||
@@ -70,6 +78,7 @@ class PluginRegistry {
|
||||
id: entry.id,
|
||||
serverId,
|
||||
clientBundle: entry.clientBundle,
|
||||
requirements: entry.requirements,
|
||||
queryClient: new QueryClient(),
|
||||
cleanup: () => undefined,
|
||||
surfaces: [],
|
||||
@@ -86,7 +95,7 @@ class PluginRegistry {
|
||||
const evaluated = runPluginClientBundle(
|
||||
entry.id,
|
||||
entry.clientBundle,
|
||||
createPluginClientRuntime(installation, options.client),
|
||||
this.dependencies.createRuntime(installation, options.client),
|
||||
() => this.publish(),
|
||||
);
|
||||
Object.assign(installation, evaluated);
|
||||
@@ -149,7 +158,10 @@ class PluginRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginRegistry = new PluginRegistry();
|
||||
export const pluginRegistry = new PluginRegistry({
|
||||
version: resolveAppVersion(),
|
||||
createRuntime: createPluginClientRuntime,
|
||||
});
|
||||
|
||||
export function useInstalledPlugins(): InstalledPlugin[] {
|
||||
return useSyncExternalStore(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import appPackage from "../../../package.json";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
@@ -111,9 +112,13 @@ afterEach(() => {
|
||||
|
||||
describe("PluginTimelineItemView", () => {
|
||||
it("validates and renders the matching plugin component", () => {
|
||||
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: bundle }], {
|
||||
client: daemonClient,
|
||||
});
|
||||
pluginRegistry.installCatalog(
|
||||
"host-1",
|
||||
[{ id: "reports", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }],
|
||||
{
|
||||
client: daemonClient,
|
||||
},
|
||||
);
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={timelineItem} />,
|
||||
@@ -125,9 +130,19 @@ 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 }], {
|
||||
client: daemonClient,
|
||||
});
|
||||
pluginRegistry.installCatalog(
|
||||
"host-1",
|
||||
[
|
||||
{
|
||||
id: "reports",
|
||||
requirements: { paseo: `>=${appPackage.version}` },
|
||||
clientBundle: failingBundle,
|
||||
},
|
||||
],
|
||||
{
|
||||
client: daemonClient,
|
||||
},
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.appendChild(container);
|
||||
@@ -152,9 +167,19 @@ 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 }], {
|
||||
client: daemonClient,
|
||||
});
|
||||
pluginRegistry.installCatalog(
|
||||
"host-1",
|
||||
[
|
||||
{
|
||||
id: "reports",
|
||||
requirements: { paseo: `>=${appPackage.version}` },
|
||||
clientBundle: recoveringBundle,
|
||||
},
|
||||
],
|
||||
{
|
||||
client: daemonClient,
|
||||
},
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.appendChild(container);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { PluginRequirements } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
@@ -36,6 +37,7 @@ export interface EvaluatedPlugin {
|
||||
|
||||
export interface InstalledPlugin extends EvaluatedPlugin {
|
||||
serverId: string;
|
||||
requirements?: PluginRequirements;
|
||||
clientBundle: string;
|
||||
queryClient: QueryClient;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import appPackage from "../../../package.json";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { pluginRegistry } from "../registry";
|
||||
@@ -30,9 +31,13 @@ function install(locations: readonly ("workspace" | "explorer")[]) {
|
||||
return function() {};
|
||||
}};
|
||||
})`;
|
||||
pluginRegistry.installCatalog("host-1", [{ id: "review", clientBundle: bundle }], {
|
||||
client: {} as DaemonClient,
|
||||
});
|
||||
pluginRegistry.installCatalog(
|
||||
"host-1",
|
||||
[{ id: "review", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }],
|
||||
{
|
||||
client: {} as DaemonClient,
|
||||
},
|
||||
);
|
||||
return pluginRegistry.getSnapshot()[0]!;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,14 +45,15 @@ describe("plugin scaffold", () => {
|
||||
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",
|
||||
},
|
||||
);
|
||||
const cliPackageJson = JSON.parse(
|
||||
await readFile(new URL("../../../package.json", import.meta.url), "utf8"),
|
||||
) as { version: string };
|
||||
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual(
|
||||
{
|
||||
id: "hello-plugin",
|
||||
requirements: { paseo: `>=${cliPackageJson.version}` },
|
||||
},
|
||||
);
|
||||
expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({
|
||||
name: "hello-plugin",
|
||||
private: true,
|
||||
|
||||
@@ -142,13 +142,14 @@ export async function scaffoldPluginDirectory(
|
||||
throw new Error(`Plugin directory must be empty: ${directory}`);
|
||||
}
|
||||
|
||||
const version = resolveCliVersion();
|
||||
const packageJson = {
|
||||
name: id,
|
||||
private: true,
|
||||
version: "0.0.0",
|
||||
scripts: { typecheck: "tsc --noEmit" },
|
||||
devDependencies: {
|
||||
"@getpaseo/plugin": resolveCliVersion(),
|
||||
"@getpaseo/plugin": version,
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@types/react": "~19.2.0",
|
||||
react: "19.1.0",
|
||||
@@ -158,7 +159,10 @@ export async function scaffoldPluginDirectory(
|
||||
},
|
||||
};
|
||||
const files = new Map<string, string>([
|
||||
["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`],
|
||||
[
|
||||
"paseo-plugin.json",
|
||||
`${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`,
|
||||
],
|
||||
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
||||
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
|
||||
["index.client.tsx", CLIENT_ENTRY],
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { renderError, toCommandError } from "./index.js";
|
||||
|
||||
it("preserves the message of an Error with an RPC code in JSON output", () => {
|
||||
const error = Object.assign(new Error("Plugin requires Paseo >=0.8.0. Your daemon is 0.7.2."), {
|
||||
code: "handler_error",
|
||||
requestId: "request-1",
|
||||
});
|
||||
expect(JSON.parse(renderError(toCommandError(error), { format: "json" }))).toEqual({
|
||||
error: {
|
||||
code: "handler_error",
|
||||
requestId: "request-1",
|
||||
message: "Plugin requires Paseo >=0.8.0. Your daemon is 0.7.2.",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,8 @@ export function render<T>(
|
||||
/** Convert an unknown error to a CommandError */
|
||||
export function toCommandError(error: unknown): CommandError {
|
||||
if (isCommandError(error)) {
|
||||
return error;
|
||||
// Error.message and class getters are not enumerable; materialize the output contract.
|
||||
return { ...error, code: error.code, message: error.message, details: error.details };
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
import { resolveCliVersion } from "../../src/version.js";
|
||||
import { readPluginManifest } from "../../../server/src/server/plugins/manifest.js";
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -26,7 +28,16 @@ async function main(): Promise<void> {
|
||||
const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-cli-e2e-"));
|
||||
const context = await createE2ETestContext({ timeout: 45_000 });
|
||||
try {
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "cli-e2e" }));
|
||||
const scaffold = path.join(context.workDir, "authored-plugin");
|
||||
const init = await context.paseo(["plugin", "init", scaffold, "--json"]);
|
||||
assert.equal(init.exitCode, 0, init.stderr);
|
||||
const manifestPath = path.join(scaffold, "paseo-plugin.json");
|
||||
const manifest = await readPluginManifest(scaffold);
|
||||
assert.deepEqual(manifest.requirements, { paseo: `>=${resolveCliVersion()}` });
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "cli-e2e", requirements: { paseo: `>=${resolveCliVersion()}` } }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
|
||||
|
||||
const install = await context.paseo(["plugin", "install", directory, "--json"]);
|
||||
@@ -37,12 +48,30 @@ async function main(): Promise<void> {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.close();
|
||||
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify({ ...manifest, requirements: { paseo: ">=999.0.0" } }),
|
||||
);
|
||||
const incompatibleInstall = await context.paseo(["plugin", "install", scaffold, "--json"]);
|
||||
assert.equal(incompatibleInstall.exitCode, 1);
|
||||
assert.match(incompatibleInstall.stderr, /requires Paseo >=999.0.0/);
|
||||
const afterRejection = await context.paseo(["plugin", "ls", "--json"]);
|
||||
assert.equal(afterRejection.exitCode, 0, afterRejection.stderr);
|
||||
assert.deepEqual(
|
||||
JSON.parse(afterRejection.stdout).map((plugin: { id: string }) => plugin.id),
|
||||
["cli-e2e"],
|
||||
);
|
||||
await writeFile(manifestPath, JSON.stringify(manifest));
|
||||
const scaffoldInstall = await context.paseo(["plugin", "install", scaffold, "--json"]);
|
||||
assert.equal(scaffoldInstall.exitCode, 0, scaffoldInstall.stderr);
|
||||
assert.equal(JSON.parse(scaffoldInstall.stdout).status, "running");
|
||||
|
||||
await git(gitDirectory, ["init", "-b", "main"]);
|
||||
await git(gitDirectory, ["config", "user.name", "Paseo Tests"]);
|
||||
await git(gitDirectory, ["config", "user.email", "paseo@example.test"]);
|
||||
await writeFile(
|
||||
path.join(gitDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "git-cli-e2e" }),
|
||||
JSON.stringify({ id: "git-cli-e2e", requirements: { paseo: `>=${resolveCliVersion()}` } }),
|
||||
);
|
||||
await writeFile(path.join(gitDirectory, "index.server.ts"), pluginSource);
|
||||
await git(gitDirectory, ["add", "-A"]);
|
||||
@@ -65,12 +94,52 @@ async function main(): Promise<void> {
|
||||
await git(gitDirectory, ["commit", "-m", "update"]);
|
||||
const status = await context.paseo(["plugin", "status", "git-cli-e2e", "--json"]);
|
||||
assert.equal(status.exitCode, 0, status.stderr);
|
||||
assert.equal(JSON.parse(status.stdout)[0].updateAvailable, true);
|
||||
assert.equal(JSON.parse(status.stdout)[0].status, "running");
|
||||
assert.equal(JSON.parse(status.stdout)[0].commit, JSON.parse(gitInstall.stdout).commit);
|
||||
|
||||
const update = await context.paseo(["plugin", "update", "git-cli-e2e", "--json"]);
|
||||
assert.equal(update.exitCode, 0, update.stderr);
|
||||
assert.equal(JSON.parse(update.stdout)[0].updated, true);
|
||||
|
||||
const installedCommit = JSON.parse(update.stdout)[0].currentCommit;
|
||||
const buildMarker = path.join(context.workDir, "incompatible-build-ran");
|
||||
await writeFile(
|
||||
path.join(gitDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "git-cli-e2e",
|
||||
requirements: { paseo: ">=999.0.0" },
|
||||
build: [
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
'require("node:fs").writeFileSync(process.argv[1], "ran")',
|
||||
buildMarker,
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
await git(gitDirectory, ["add", "-A"]);
|
||||
await git(gitDirectory, ["commit", "-m", "requires a future Paseo"]);
|
||||
const incompatibleUpdate = await context.paseo(["plugin", "update", "git-cli-e2e", "--json"]);
|
||||
assert.equal(incompatibleUpdate.exitCode, 1);
|
||||
assert.match(incompatibleUpdate.stderr, /requires Paseo >=999.0.0/);
|
||||
await assert.rejects(readFile(buildMarker), { code: "ENOENT" });
|
||||
const retained = await context.paseo(["plugin", "ls", "git-cli-e2e", "--json"]);
|
||||
assert.equal(retained.exitCode, 0, retained.stderr);
|
||||
assert.equal(JSON.parse(retained.stdout)[0].commit, installedCommit);
|
||||
assert.equal(JSON.parse(retained.stdout)[0].status, "running");
|
||||
const incompatibleAdd = await context.paseo([
|
||||
"plugin",
|
||||
"add",
|
||||
pathToFileURL(gitDirectory).href,
|
||||
"--id",
|
||||
"future-plugin",
|
||||
"--json",
|
||||
]);
|
||||
assert.equal(incompatibleAdd.exitCode, 1);
|
||||
assert.match(incompatibleAdd.stderr, /requires Paseo >=999.0.0/);
|
||||
await assert.rejects(readFile(buildMarker), { code: "ENOENT" });
|
||||
|
||||
const reload = await context.paseo(["plugin", "reload", "cli-e2e", "--json"]);
|
||||
assert.equal(reload.exitCode, 0, reload.stderr);
|
||||
assert.equal(JSON.parse(reload.stdout).status, "running");
|
||||
@@ -87,6 +156,8 @@ async function main(): Promise<void> {
|
||||
assert.equal(remove.exitCode, 0, remove.stderr);
|
||||
const removeGit = await context.paseo(["plugin", "remove", "git-cli-e2e", "--json"]);
|
||||
assert.equal(removeGit.exitCode, 0, removeGit.stderr);
|
||||
const removeScaffold = await context.paseo(["plugin", "remove", "authored-plugin", "--json"]);
|
||||
assert.equal(removeScaffold.exitCode, 0, removeScaffold.stderr);
|
||||
const list = await context.paseo(["plugin", "ls", "--json"]);
|
||||
assert.equal(list.exitCode, 0, list.stderr);
|
||||
assert.deepEqual(JSON.parse(list.stdout), []);
|
||||
|
||||
@@ -4918,7 +4918,7 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getPluginCatalog(): Promise<Array<{ id: string; clientBundle: string }>> {
|
||||
async getPluginCatalog() {
|
||||
const requestId = this.createRequestId();
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
|
||||
@@ -31,10 +31,12 @@
|
||||
"pretest": "npm run generate:validators"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/semver": "^7.8.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6",
|
||||
"zod-aot": "0.20.4"
|
||||
|
||||
@@ -7,6 +7,21 @@ import {
|
||||
} from "./messages.js";
|
||||
|
||||
describe("plugin protocol compatibility", () => {
|
||||
it.each([undefined, { paseo: ">=0.8.0" }])(
|
||||
"parses plugin catalogs with requirements %j",
|
||||
(requirements) => {
|
||||
const message = {
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: "catalog",
|
||||
plugins: [
|
||||
{ id: "example", clientBundle: "bundle", ...(requirements ? { requirements } : {}) },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||
},
|
||||
);
|
||||
it("parses plugin timeline append messages and advertises the capability", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
|
||||
@@ -192,6 +192,9 @@ const MutableRelayConfigSchema = z
|
||||
.passthrough();
|
||||
|
||||
export const PluginIdSchema = z.string().regex(/^[a-z][a-z0-9-]*$/);
|
||||
// Semver validation belongs at the manifest/runtime boundary, not on the wire.
|
||||
export const PluginRequirementsSchema = z.object({ paseo: z.string().optional() });
|
||||
export type PluginRequirements = z.infer<typeof PluginRequirementsSchema>;
|
||||
|
||||
export const DirectoryPluginSourceSchema = z
|
||||
.object({
|
||||
@@ -6262,6 +6265,7 @@ export const PluginCatalogGetResponseSchema = z.object({
|
||||
z.object({
|
||||
id: PluginIdSchema,
|
||||
clientBundle: z.string(),
|
||||
requirements: PluginRequirementsSchema.optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertPluginCompatibility, validatePluginRequirements } from "./plugin-requirements.js";
|
||||
|
||||
describe("plugin requirements", () => {
|
||||
it("rejects legacy manifests on the first breaking release with migration instructions", () => {
|
||||
expect(() =>
|
||||
assertPluginCompatibility({ id: "legacy", version: "0.8.0", runtime: "daemon" }),
|
||||
).toThrow(/legacy.*<0\.8\.0.*0\.8\.0.*https:\/\/paseo.sh\/docs\/plugins\/v0.8\/migration/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, "0.7.2"],
|
||||
[">=0.8.0", "0.8.0"],
|
||||
[">=0.8.0", "1.0.0"],
|
||||
["^0.8.0", "0.8.4"],
|
||||
[">=0.8.0-beta.1", "0.8.0-beta.2"],
|
||||
[">=0.8.0-beta.1", "0.8.0"],
|
||||
["^0.8.0 || ^0.9.0", "0.9.2+build.42"],
|
||||
])("accepts %s on %s", (paseo, version) => {
|
||||
expect(() =>
|
||||
assertPluginCompatibility({ id: "test", requirements: { paseo }, version, runtime: "app" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, "0.8.0-beta.1"],
|
||||
[undefined, "0.9.0"],
|
||||
[">=0.8.0", "0.7.2"],
|
||||
["^0.8.0", "0.9.0"],
|
||||
[">=0.8.0", "0.8.0-beta.1"],
|
||||
])("rejects %s on %s", (paseo, version) => {
|
||||
expect(() =>
|
||||
assertPluginCompatibility({ id: "test", requirements: { paseo }, version, runtime: "app" }),
|
||||
).toThrow(`Your app is ${version}`);
|
||||
});
|
||||
|
||||
it.each(["", " ", "latest", ">=potato", "0.8.0 nonsense"])(
|
||||
"rejects malformed range %s",
|
||||
(paseo) => {
|
||||
expect(() => validatePluginRequirements({ paseo })).toThrow("Invalid requirements.paseo");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, "unknown"])("fails closed when the runtime version is %s", (version) => {
|
||||
expect(() =>
|
||||
assertPluginCompatibility({
|
||||
id: "test",
|
||||
requirements: { paseo: "*" },
|
||||
version,
|
||||
runtime: "app",
|
||||
}),
|
||||
).toThrow("Paseo app version is unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PluginRequirements } from "./messages.js";
|
||||
import valid from "semver/functions/valid.js";
|
||||
import validRange from "semver/ranges/valid.js";
|
||||
import satisfies from "semver/functions/satisfies.js";
|
||||
|
||||
export function validatePluginRequirements(requirements: PluginRequirements | undefined): void {
|
||||
const range = requirements?.paseo;
|
||||
if (range !== undefined && (!range.trim() || validRange(range) === null)) {
|
||||
throw new Error(
|
||||
`Invalid requirements.paseo: ${JSON.stringify(range)}. Use an npm semver range such as ">=0.8.0".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface PluginCompatibilityInput {
|
||||
id: string;
|
||||
requirements?: PluginRequirements;
|
||||
version: string | null;
|
||||
runtime: "daemon" | "app";
|
||||
}
|
||||
|
||||
export function assertPluginCompatibility(input: PluginCompatibilityInput): void {
|
||||
validatePluginRequirements(input.requirements);
|
||||
// COMPAT(plugin-requirements): added in v0.8.0; remove after 2027-03-07 once pre-0.8 plugins and catalogs are unsupported.
|
||||
const range = input.requirements?.paseo ?? "<0.8.0";
|
||||
if (!input.version || !valid(input.version)) {
|
||||
throw new Error(
|
||||
`Cannot check plugin "${input.id}" requirements: Paseo ${input.runtime} version is unknown. Update the ${input.runtime}.`,
|
||||
);
|
||||
}
|
||||
if (satisfies(input.version, range)) return;
|
||||
const action =
|
||||
input.requirements?.paseo === undefined
|
||||
? "This plugin has no requirements.paseo and targets Paseo before 0.8. Ask its author to migrate it: https://paseo.sh/docs/plugins/v0.8/migration"
|
||||
: `Use a compatible plugin version or update the ${input.runtime}.`;
|
||||
throw new Error(
|
||||
`Plugin "${input.id}" requires Paseo ${range}. Your ${input.runtime} is ${input.version}. ${action}`,
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
import os from "node:os";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
@@ -672,7 +673,10 @@ describe("paseo daemon bootstrap", () => {
|
||||
await mkdir(pluginDirectory);
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "startup-rollback" }),
|
||||
JSON.stringify({
|
||||
id: "startup-rollback",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "index.server.ts"),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveDaemonVersion } from "../daemon-version.js";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -114,7 +115,10 @@ test("plugin handlers operate terminals through their host-owned Paseo API", asy
|
||||
await mkdir(pluginDirectory);
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "terminal-sdk" }),
|
||||
JSON.stringify({
|
||||
id: "terminal-sdk",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "index.server.ts"),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type PluginSourceUpdateItem,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
|
||||
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
|
||||
import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
|
||||
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js";
|
||||
@@ -23,7 +24,7 @@ import { readPluginProviderIcon } from "./provider-icon.js";
|
||||
const BUILTIN_PROVIDER_ID_SET: ReadonlySet<string> = new Set(BUILTIN_PROVIDER_IDS);
|
||||
|
||||
interface PluginRuntimePort {
|
||||
catalog(): Array<{ id: string; clientBundle: string }>;
|
||||
catalog: PluginRuntime["catalog"];
|
||||
invoke(pluginId: string, method: string, input: unknown): Promise<unknown>;
|
||||
getLogs(pluginId: string): PluginLogEntry[];
|
||||
clearLogs(pluginId: string): void;
|
||||
@@ -70,7 +71,7 @@ export class PluginService {
|
||||
constructor(
|
||||
logger: pino.Logger,
|
||||
private readonly configStore: DaemonConfigStore,
|
||||
daemonVersion: string,
|
||||
private readonly daemonVersion: string,
|
||||
private readonly dependencies: PluginServiceDependencies = {},
|
||||
) {
|
||||
this.logger = logger.child({ module: "plugin-service" });
|
||||
@@ -166,7 +167,7 @@ export class PluginService {
|
||||
return this.runtime.getLogs(pluginId);
|
||||
}
|
||||
|
||||
catalog(): Array<{ id: string; clientBundle: string }> {
|
||||
catalog(): ReturnType<PluginRuntime["catalog"]> {
|
||||
return this.runtime.catalog();
|
||||
}
|
||||
|
||||
@@ -174,6 +175,7 @@ export class PluginService {
|
||||
return this.enqueue(async () => {
|
||||
const directory = path.resolve(input.path);
|
||||
const manifest = await readPluginManifest(directory);
|
||||
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
|
||||
const pluginId = PluginIdSchema.parse(input.id ?? manifest.id);
|
||||
if (this.configStore.get().plugins?.[pluginId]) {
|
||||
throw new Error(
|
||||
@@ -225,6 +227,7 @@ export class PluginService {
|
||||
});
|
||||
let pluginId: string;
|
||||
try {
|
||||
await this.checkRequirements(candidate.directory);
|
||||
await runPluginBuild(candidate.directory, candidate.build, this.logger);
|
||||
pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId);
|
||||
if (this.configStore.get().plugins?.[pluginId]) {
|
||||
@@ -508,6 +511,11 @@ export class PluginService {
|
||||
this.errors.set(pluginId, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
private async checkRequirements(directory: string): Promise<void> {
|
||||
const manifest = await readPluginManifest(directory);
|
||||
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
|
||||
}
|
||||
|
||||
private async updateSource(pluginId: string): Promise<PluginSourceUpdateItem> {
|
||||
const managedSources = this.requireManagedSources();
|
||||
const source = this.requireSource(pluginId);
|
||||
@@ -525,6 +533,7 @@ export class PluginService {
|
||||
}
|
||||
let candidate = prepared.candidate;
|
||||
try {
|
||||
await this.checkRequirements(candidate.directory);
|
||||
await runPluginBuild(candidate.directory, candidate.build, this.logger);
|
||||
candidate = await managedSources.place(pluginId, candidate);
|
||||
await this.validateCandidate(candidate);
|
||||
|
||||
@@ -11,6 +11,27 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("plugin manifest", () => {
|
||||
it("reads and validates requirements before any plugin code runs", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
const manifest = path.join(directory, "paseo-plugin.json");
|
||||
await writeFile(manifest, JSON.stringify({ id: "example", requirements: { paseo: "^0.8.0" } }));
|
||||
await expect(readPluginManifest(directory)).resolves.toEqual({
|
||||
id: "example",
|
||||
requirements: { paseo: "^0.8.0" },
|
||||
});
|
||||
for (const requirements of [
|
||||
{ paseo: "latest" },
|
||||
{ paseo: "" },
|
||||
{ paseo: 8 },
|
||||
{ node: ">=20" },
|
||||
"0.8.0",
|
||||
]) {
|
||||
await writeFile(manifest, JSON.stringify({ id: "example", requirements }));
|
||||
await expect(readPluginManifest(directory)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts only non-empty argv arrays for build commands", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { PluginIdSchema } from "@getpaseo/protocol/messages";
|
||||
import { PluginIdSchema, PluginRequirementsSchema } from "@getpaseo/protocol/messages";
|
||||
import { validatePluginRequirements } from "@getpaseo/protocol/plugin-requirements";
|
||||
|
||||
const MANIFEST_FILENAME = "paseo-plugin.json";
|
||||
const PluginBuildCommandSchema = z
|
||||
@@ -10,6 +11,7 @@ const PluginBuildCommandSchema = z
|
||||
const PluginManifestSchema = z
|
||||
.object({
|
||||
id: PluginIdSchema,
|
||||
requirements: PluginRequirementsSchema.strict().optional(),
|
||||
build: z.array(PluginBuildCommandSchema).min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -20,5 +22,7 @@ export async function readPluginManifest(directory: string): Promise<PluginManif
|
||||
const manifestPath = path.join(directory, MANIFEST_FILENAME);
|
||||
const info = await stat(manifestPath).catch(() => null);
|
||||
if (!info?.isFile()) throw new Error(`Plugin manifest is missing: ${manifestPath}`);
|
||||
return PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
|
||||
const manifest = PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
|
||||
validatePluginRequirements(manifest.requirements);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveDaemonVersion } from "../daemon-version.js";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -18,7 +19,10 @@ test("plugin handlers create workspaces and agents through their Paseo API", asy
|
||||
roots.push(pluginDirectory, workspaceDirectory);
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "paseo-api" }),
|
||||
JSON.stringify({
|
||||
id: "paseo-api",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "index.server.ts"),
|
||||
@@ -141,7 +145,10 @@ test("daemon config reload enables and disables configured plugins without resta
|
||||
roots.push(pluginDirectory, paseoHomeRoot);
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "reloadable-plugin" }),
|
||||
JSON.stringify({
|
||||
id: "reloadable-plugin",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "index.server.ts"),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import pino from "pino";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import { runGitCommand } from "../../utils/run-git-command.js";
|
||||
import { PluginService } from "./index.js";
|
||||
import { ManagedPluginSources } from "./managed-source.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const services: PluginService[] = [];
|
||||
async function directory() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "plugin-requirements-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
async function writePlugin(root: string, paseo?: string, build?: string[][]) {
|
||||
await writeFile(
|
||||
path.join(root, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "example",
|
||||
requirements: paseo === undefined ? undefined : { paseo },
|
||||
build,
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(root, "index.client.ts"),
|
||||
"export default function contribute() { return () => {}; }",
|
||||
);
|
||||
}
|
||||
async function host(version = "0.8.0", pluginPath?: string) {
|
||||
const home = await directory();
|
||||
const store = new DaemonConfigStore(home, {
|
||||
mcp: { injectIntoAgents: true },
|
||||
browserTools: { enabled: false },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
pluginsEnabled: true,
|
||||
plugins: pluginPath
|
||||
? { example: { source: "directory", path: pluginPath, enabled: true } }
|
||||
: {},
|
||||
});
|
||||
const service = new PluginService(pino({ level: "silent" }), store, version, {
|
||||
managedSources: new ManagedPluginSources(home),
|
||||
});
|
||||
services.push(service);
|
||||
await service.start();
|
||||
return { home, store, service };
|
||||
}
|
||||
afterEach(async () => {
|
||||
await Promise.all(services.splice(0).map((service) => service.stopAllPlugins()));
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("rejects incompatible installs without persisting them, then accepts the corrected manifest", async () => {
|
||||
const root = await directory();
|
||||
await writePlugin(root, ">=0.9.0");
|
||||
const { service, store } = await host();
|
||||
await expect(service.installDirectory({ path: root })).rejects.toThrow("Your daemon is 0.8.0");
|
||||
expect(store.get().plugins).toEqual({});
|
||||
expect(service.catalog()).toEqual([]);
|
||||
await writePlugin(root, "^0.8.0");
|
||||
await expect(service.installDirectory({ path: root })).resolves.toMatchObject({
|
||||
status: "running",
|
||||
});
|
||||
expect(service.catalog()).toEqual([
|
||||
{ id: "example", requirements: { paseo: "^0.8.0" }, clientBundle: expect.any(String) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks pre-0.8 plugins failed on startup and recovers after migration and reload", async () => {
|
||||
const root = await directory();
|
||||
await writePlugin(root);
|
||||
const { service } = await host("0.8.0", root);
|
||||
expect(service.listPlugins()).toEqual([
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
error: expect.stringContaining("https://paseo.sh/docs/plugins/v0.8/migration"),
|
||||
}),
|
||||
]);
|
||||
await writePlugin(root, ">=0.8.0");
|
||||
await expect(service.reloadPlugin("example")).resolves.toMatchObject({ status: "running" });
|
||||
await writePlugin(root, ">=0.9.0");
|
||||
await expect(service.reloadPlugin("example")).rejects.toThrow("requires Paseo >=0.9.0");
|
||||
expect(service.catalog()).toEqual([]);
|
||||
});
|
||||
|
||||
it("still requires entry migration when an old plugin adds a compatible requirement", async () => {
|
||||
const root = await directory();
|
||||
await writePlugin(root, ">=0.8.0");
|
||||
await rm(path.join(root, "index.client.ts"));
|
||||
await writeFile(path.join(root, "index.ts"), "export default () => () => {};");
|
||||
const { service } = await host();
|
||||
await expect(service.installDirectory({ path: root })).rejects.toThrow(
|
||||
"https://paseo.sh/docs/plugins/v0.8/migration",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects Git install and update before build commands, preserving the running revision", async () => {
|
||||
const repository = await directory();
|
||||
await runGitCommand(["init", "-b", "main"], { cwd: repository });
|
||||
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository });
|
||||
await runGitCommand(["config", "user.email", "tests@example.test"], { cwd: repository });
|
||||
const commit = async () => {
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(["commit", "-m", "plugin"], { cwd: repository });
|
||||
};
|
||||
await writePlugin(repository, ">=0.8.0");
|
||||
await commit();
|
||||
const { service, home } = await host();
|
||||
const source = pathToFileURL(repository).href;
|
||||
const installed = await service.installSource({ source });
|
||||
const marker = path.join(home, "build-executed");
|
||||
await writePlugin(repository, ">=0.9.0", [
|
||||
[process.execPath, "-e", 'require("node:fs").writeFileSync(process.argv[1], "ran")', marker],
|
||||
]);
|
||||
await commit();
|
||||
await expect(service.updateSources("example")).rejects.toThrow("requires Paseo >=0.9.0");
|
||||
expect(service.listPlugins()).toEqual([installed]);
|
||||
expect(service.catalog()).toHaveLength(1);
|
||||
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
|
||||
await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(service.installSource({ source, id: "second" })).rejects.toThrow(
|
||||
"requires Paseo >=0.9.0",
|
||||
);
|
||||
await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(service.listPlugins()).toEqual([installed]);
|
||||
});
|
||||
@@ -88,8 +88,9 @@ function createReloadChild(
|
||||
function createTestRuntime(
|
||||
dependencies: NonNullable<ConstructorParameters<typeof PluginRuntime>[2]> = {},
|
||||
logger = pino({ level: "silent" }),
|
||||
version = "0.4.0",
|
||||
): PluginRuntime {
|
||||
return new PluginRuntime(logger, "0.4.0", {
|
||||
return new PluginRuntime(logger, version, {
|
||||
...dependencies,
|
||||
sessionHost: dependencies.sessionHost ?? {
|
||||
async attachPluginSocket(_pluginId, socket) {
|
||||
@@ -107,7 +108,7 @@ function createTestRuntime(
|
||||
status: "server_info",
|
||||
serverId: "plugin-test",
|
||||
hostname: "plugin-test",
|
||||
version: "0.4.0",
|
||||
version,
|
||||
features: {},
|
||||
},
|
||||
},
|
||||
@@ -211,7 +212,7 @@ describe("PluginRuntime", () => {
|
||||
const directory = fileURLToPath(
|
||||
new URL("../../../../../plugin-examples/provider-direct/", import.meta.url),
|
||||
);
|
||||
const runtime = createTestRuntime();
|
||||
const runtime = createTestRuntime({}, undefined, "0.8.0");
|
||||
await runtime.startPlugin(pluginId, directory);
|
||||
const [metadata] = runtime.getProviderRegistrations(pluginId);
|
||||
expect(metadata).toBeDefined();
|
||||
@@ -1319,7 +1320,7 @@ export default function contribute(server: { registerProvider(provider: Provider
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../plugin-examples/linear",
|
||||
);
|
||||
const runtime = createTestRuntime();
|
||||
const runtime = createTestRuntime({}, undefined, "0.8.0");
|
||||
|
||||
await runtime.startPlugin("linear", directory);
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
import type { PluginLogEntry } from "@getpaseo/protocol/messages";
|
||||
import { compilePlugin } from "./compiler.js";
|
||||
import { readPluginManifest } from "./manifest.js";
|
||||
import type { PluginRequirements } from "@getpaseo/protocol/messages";
|
||||
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
|
||||
import type {
|
||||
PluginProcessMessage,
|
||||
PluginProcessRequest,
|
||||
@@ -56,6 +58,7 @@ interface PendingInvocation {
|
||||
|
||||
interface LoadedPlugin {
|
||||
id: string;
|
||||
requirements: PluginRequirements | undefined;
|
||||
clientBundle: string;
|
||||
methods: ReadonlySet<string>;
|
||||
providers: readonly PluginProviderMetadata[];
|
||||
@@ -312,7 +315,8 @@ export class PluginRuntime {
|
||||
|
||||
async validatePlugin(configuredPath: string): Promise<void> {
|
||||
const directory = path.resolve(configuredPath);
|
||||
await readPluginManifest(directory);
|
||||
const manifest = await readPluginManifest(directory);
|
||||
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
|
||||
const entryPaths = await resolveEntryPaths(directory);
|
||||
await compilePlugin(entryPaths);
|
||||
}
|
||||
@@ -326,9 +330,9 @@ export class PluginRuntime {
|
||||
return true;
|
||||
}
|
||||
|
||||
catalog(): Array<{ id: string; clientBundle: string }> {
|
||||
catalog(): Array<{ id: string; clientBundle: string; requirements?: PluginRequirements }> {
|
||||
return [...this.plugins.values()]
|
||||
.map(({ id, clientBundle }) => ({ id, clientBundle }))
|
||||
.map(({ id, clientBundle, requirements }) => ({ id, clientBundle, requirements }))
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
@@ -473,7 +477,8 @@ export class PluginRuntime {
|
||||
configuredPath: string,
|
||||
): Promise<LoadedPlugin> {
|
||||
const directory = path.resolve(configuredPath);
|
||||
await readPluginManifest(directory);
|
||||
const manifest = await readPluginManifest(directory);
|
||||
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
|
||||
const entryPaths = await resolveEntryPaths(directory);
|
||||
const bundles = await compilePlugin(entryPaths);
|
||||
const serverBundle = bundles.serverBundle;
|
||||
@@ -481,6 +486,7 @@ export class PluginRuntime {
|
||||
return {
|
||||
id: pluginId,
|
||||
clientBundle: bundles.clientBundle ?? "",
|
||||
requirements: manifest.requirements,
|
||||
methods: new Set(),
|
||||
providers: [],
|
||||
child: null,
|
||||
@@ -576,6 +582,7 @@ export class PluginRuntime {
|
||||
loaded = {
|
||||
id: pluginId,
|
||||
clientBundle: bundles.clientBundle ?? "",
|
||||
requirements: manifest.requirements,
|
||||
methods: new Set(ready.methods),
|
||||
providers: ready.providers ?? [],
|
||||
child,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveDaemonVersion } from "../daemon-version.js";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -19,7 +20,10 @@ test("two clients share settings, observe changes, and preserve values through p
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "settings-test" }),
|
||||
JSON.stringify({
|
||||
id: "settings-test",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Browser compatibility tests run a real daemon in a separate Node runtime.
|
||||
import { createTestPaseoDaemon } from "./paseo-daemon.js";
|
||||
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
daemonVersion: process.argv[2],
|
||||
pluginsEnabled: true,
|
||||
mcpEnabled: false,
|
||||
corsAllowedOrigins: ["*"],
|
||||
});
|
||||
const response = await fetch(`http://127.0.0.1:${daemon.port}/api/status`);
|
||||
const { serverId } = await response.json();
|
||||
process.send?.({ port: daemon.port, serverId });
|
||||
process.once("SIGTERM", async () => {
|
||||
await daemon.close();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"id": "catppuccin"
|
||||
"id": "catppuccin",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"id": "inline-thinking"
|
||||
"id": "inline-thinking",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"id": "linear"
|
||||
"id": "linear",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"id": "local-example"
|
||||
"id": "local-example",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"id": "modal-ui-example"
|
||||
"id": "modal-ui-example",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
"id": "provider-acp-transformer-example",
|
||||
"name": "ACP provider transformer example",
|
||||
"version": "1.0.0",
|
||||
"description": "Wraps an ACP command and normalizes one vendor file-edit shape."
|
||||
"description": "Wraps an ACP command and normalizes one vendor file-edit shape.",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"id": "provider-direct-example"
|
||||
"id": "provider-direct-example",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
{ "id": "settings-example" }
|
||||
{
|
||||
"id": "settings-example",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"id": "pi-tasks-timeline"
|
||||
"id": "pi-tasks-timeline",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ greeting through an RPC.
|
||||
|
||||
```text
|
||||
workspace-plugin/
|
||||
paseo-plugin.json # { "id": "workspace-plugin" }
|
||||
paseo-plugin.json # plugin ID and supported Paseo versions
|
||||
index.client.tsx # runs in the Paseo app
|
||||
index.server.ts # runs in a daemon subprocess
|
||||
client/greeting.tsx # the surface component
|
||||
|
||||
@@ -102,6 +102,7 @@ its registration; that registration belongs in the client entry.
|
||||
|
||||
| Compiler or load error | Meaning and fix |
|
||||
| -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `This plugin has no requirements.paseo` | Complete the migration and declare the range in step 7. |
|
||||
| `This plugin was made for an older version of Paseo` | 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/`. |
|
||||
@@ -221,7 +222,26 @@ Import path changes inside the moved files:
|
||||
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
|
||||
## 7. Declare the Paseo requirement
|
||||
|
||||
After migrating the entries and imports, add the minimum runtime version to `paseo-plugin.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin",
|
||||
"requirements": { "paseo": ">=0.8.0" }
|
||||
}
|
||||
```
|
||||
|
||||
Keep your existing ID and build commands. Missing `requirements.paseo` means `<0.8.0`, so Paseo 0.8
|
||||
rejects the plugin even if its files have been moved. Adding the field alone does not migrate the
|
||||
code. Update the local `@getpaseo/plugin` development dependency to the version you target and
|
||||
reinstall dependencies before typechecking.
|
||||
|
||||
For a 0.8 beta, use its explicit version in both the SDK dependency and range, for example
|
||||
`>=0.8.0-beta.1`. See [requirements](reference#requirements) for range and prerelease semantics.
|
||||
|
||||
## 8. Verify the migration
|
||||
|
||||
Run:
|
||||
|
||||
|
||||
@@ -42,12 +42,45 @@ my-plugin/
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
The required root manifest is `paseo-plugin.json`. It contains the default plugin ID:
|
||||
The required root manifest is `paseo-plugin.json`. It contains the default plugin ID and supported Paseo versions:
|
||||
|
||||
```json
|
||||
{ "id": "my-plugin" }
|
||||
{ "id": "my-plugin", "requirements": { "paseo": ">=0.8.0" } }
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
`requirements` is an optional object. Its currently supported key, `paseo`, accepts an npm semver
|
||||
range. An omitted `requirements.paseo` means `<0.8.0`: the plugin predates the first breaking
|
||||
plugin release. Paseo 0.8 and later reject it with a link to the [migration guide](migration).
|
||||
Empty strings, invalid ranges, and unknown manifest requirement keys are rejected.
|
||||
|
||||
| Range | Compatible releases |
|
||||
| ---------------- | ------------------------------------------------------------------- |
|
||||
| `>=0.8.0` | 0.8.0 and later stable releases, including future breaking releases |
|
||||
| `^0.8.0` | 0.8.x stable releases only |
|
||||
| `>=0.8.3 <0.9.0` | 0.8.3 through the last 0.8 patch |
|
||||
| `>=0.8.0-beta.1` | Betas from 0.8.0-beta.1, then 0.8.0 and later stable releases |
|
||||
|
||||
Standard npm prerelease rules apply: `>=0.8.0` excludes `0.8.0-beta.1`. A prerelease must be
|
||||
explicitly included for its major/minor/patch tuple; no version tags are stripped.
|
||||
|
||||
`paseo plugin init` writes `>=` followed by the current CLI version and pins the matching SDK
|
||||
for typechecking. Raise the minimum when adopting a newer API. Add an upper bound when a later
|
||||
release is incompatible; a minimum alone does not promise protection from future breaking changes.
|
||||
|
||||
The daemon checks its version before installing, running Git build commands, or loading a plugin,
|
||||
and checks again on startup, enable, and reload. A rejected Git update keeps the installed revision.
|
||||
Each connected app checks its own version before evaluating client code and shows incompatibility
|
||||
in Settings → Plugins. A compatible daemon does not make an older app compatible. A plugin with no
|
||||
client entry does not require the connected app to match.
|
||||
|
||||
For example: `Plugin "review" requires Paseo >=0.8.0. Your daemon is 0.7.2.` Use a compatible plugin
|
||||
revision or update the named runtime. Releases before 0.8 do not understand this manifest field
|
||||
and cannot show this new diagnostic.
|
||||
|
||||
### Runtime entries
|
||||
|
||||
| Entry | Runtime | Receives | Required |
|
||||
| ------------------ | --------------------- | --------------------- | ----------------------------------------------------------------- |
|
||||
| `index.client.tsx` | Paseo app, per client | `PluginClientContext` | When the plugin has any UI, callback, theme, or attachment source |
|
||||
@@ -1276,6 +1309,7 @@ step:
|
||||
```json
|
||||
{
|
||||
"id": "review",
|
||||
"requirements": { "paseo": ">=0.8.0" },
|
||||
"build": [
|
||||
["npm", "ci"],
|
||||
["npm", "run", "build"]
|
||||
|
||||
@@ -62,12 +62,19 @@ my-plugin/
|
||||
shared/greeting.ts
|
||||
```
|
||||
|
||||
The manifest supplies the default install ID:
|
||||
The manifest supplies the default install ID and supported Paseo versions:
|
||||
|
||||
```json
|
||||
{ "id": "my-plugin" }
|
||||
{ "id": "my-plugin", "requirements": { "paseo": ">=0.8.0" } }
|
||||
```
|
||||
|
||||
Keep `requirements.paseo` correct whenever creating or editing a plugin. `init` uses `>=` followed
|
||||
by the CLI version. Raise the minimum when adopting newer APIs; add an upper bound when a later
|
||||
Paseo release is incompatible. Use npm semver ranges and explicitly include beta versions when
|
||||
targeting betas. Missing requirements mean `<0.8.0`; complete the 0.8 entry migration before adding
|
||||
`>=0.8.0`. Verify compatibility with both the daemon and the app running client contributions.
|
||||
See [requirements](https://paseo.sh/docs/plugins/v0.8/reference#requirements).
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user