diff --git a/docs/plugins.md b/docs/plugins.md index 3798d7c055..3dcf5716e1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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"] diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index a713307bd5..96f8a0393a 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-Vw6bgh+gPdVxySdbawKawr9qZT8QEEo2+okdpuKceMA= +sha256-1MBzb+r/t92G9uR/jBuc3oNEfEloHeRg9uUgzSHS4UM= diff --git a/package-lock.json b/package-lock.json index 37131854b5..4eb14395e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index 7fcc72bdb7..80ee02459d 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -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 { diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 823b792a5d..aaa3b2c0da 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -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 { diff --git a/packages/app/e2e/browser/plugin-modal-body.spec.ts b/packages/app/e2e/browser/plugin-modal-body.spec.ts index 591a2472e8..6db456b72b 100644 --- a/packages/app/e2e/browser/plugin-modal-body.spec.ts +++ b/packages/app/e2e/browser/plugin-modal-body.spec.ts @@ -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(); } }, }); diff --git a/packages/app/e2e/browser/plugin-requirements.spec.ts b/packages/app/e2e/browser/plugin-requirements.spec.ts new file mode 100644 index 0000000000..832e3f1611 --- /dev/null +++ b/packages/app/e2e/browser/plugin-requirements.spec.ts @@ -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); +}); diff --git a/packages/app/e2e/browser/plugin-theme.spec.ts b/packages/app/e2e/browser/plugin-theme.spec.ts index aa579ae62c..6550460345 100644 --- a/packages/app/e2e/browser/plugin-theme.spec.ts +++ b/packages/app/e2e/browser/plugin-theme.spec.ts @@ -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 { diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts index 80f19ce1e4..4e7d7682df 100644 --- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts @@ -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", diff --git a/packages/app/e2e/support/helpers/plugin-fixture.ts b/packages/app/e2e/support/helpers/plugin-fixture.ts new file mode 100644 index 0000000000..2b6e548e71 --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-fixture.ts @@ -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; + } +} diff --git a/packages/app/e2e/support/helpers/plugin-requirements.ts b/packages/app/e2e/support/helpers/plugin-requirements.ts new file mode 100644 index 0000000000..18631c319a --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-requirements.ts @@ -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({ + 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(); +} diff --git a/packages/app/e2e/support/helpers/plugin-settings.ts b/packages/app/e2e/support/helpers/plugin-settings.ts index d8dfb020e4..e391f648f2 100644 --- a/packages/app/e2e/support/helpers/plugin-settings.ts +++ b/packages/app/e2e/support/helpers/plugin-settings.ts @@ -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>; 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 }, diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts new file mode 100644 index 0000000000..0efc1facb3 --- /dev/null +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -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(); +}); diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts index a0fc4c068f..99549c465f 100644 --- a/packages/app/src/plugins/registry.test.ts +++ b/packages/app/src/plugins/registry.test.ts @@ -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[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 }, + ); }, }; diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index 8f6981e9ee..f228c7202f 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -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>[number]; -class PluginRegistry { +export class PluginRegistry { private readonly byHost = new Map(); private readonly listeners = new Set<() => void>(); private snapshot: InstalledPlugin[] = []; private readonly disposed = new WeakSet(); private readonly evaluationErrors = new Map(); + 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( diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index 5f8485b8c0..12f322921a 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -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( , @@ -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); diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 9334520ed0..9247523d29 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -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; } diff --git a/packages/app/src/plugins/workspace-panels/locations.test.ts b/packages/app/src/plugins/workspace-panels/locations.test.ts index d0e69478da..be92ee3c66 100644 --- a/packages/app/src/plugins/workspace-panels/locations.test.ts +++ b/packages/app/src/plugins/workspace-panels/locations.test.ts @@ -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]!; } diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index 79db0e2878..ff01d2730a 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -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, diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index e57e316215..d525490f07 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -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([ - ["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], diff --git a/packages/cli/src/output/render.test.ts b/packages/cli/src/output/render.test.ts new file mode 100644 index 0000000000..bc7dced1ca --- /dev/null +++ b/packages/cli/src/output/render.test.ts @@ -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.", + }, + }); +}); diff --git a/packages/cli/src/output/render.ts b/packages/cli/src/output/render.ts index f3eb9f8aec..d21e53344a 100644 --- a/packages/cli/src/output/render.ts +++ b/packages/cli/src/output/render.ts @@ -50,7 +50,8 @@ export function render( /** 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) { diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index afc28c551c..4cb407a787 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -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 { 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 { 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 { 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 { 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), []); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index eb0fcbf1a4..f67a4e7023 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -4918,7 +4918,7 @@ export class DaemonClient { }); } - async getPluginCatalog(): Promise> { + async getPluginCatalog() { const requestId = this.createRequestId(); const payload = await this.sendCorrelatedSessionRequest({ requestId, diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 676e5c5039..25cce3ad8e 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -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" diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index e5726b5081..e2be026cb0 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -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({ diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 4f6415b8c9..3c8d09d5b9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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; export const DirectoryPluginSourceSchema = z .object({ @@ -6262,6 +6265,7 @@ export const PluginCatalogGetResponseSchema = z.object({ z.object({ id: PluginIdSchema, clientBundle: z.string(), + requirements: PluginRequirementsSchema.optional(), }), ), }), diff --git a/packages/protocol/src/plugin-requirements.test.ts b/packages/protocol/src/plugin-requirements.test.ts new file mode 100644 index 0000000000..ec00f60694 --- /dev/null +++ b/packages/protocol/src/plugin-requirements.test.ts @@ -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"); + }); +}); diff --git a/packages/protocol/src/plugin-requirements.ts b/packages/protocol/src/plugin-requirements.ts new file mode 100644 index 0000000000..2e0a360099 --- /dev/null +++ b/packages/protocol/src/plugin-requirements.ts @@ -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}`, + ); +} diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 40ea69e863..697dfda8a7 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -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"), diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts index f27e478bf4..7dfc4bc510 100644 --- a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.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"), diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index acfc9eb722..459fdc7de8 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.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 = new Set(BUILTIN_PROVIDER_IDS); interface PluginRuntimePort { - catalog(): Array<{ id: string; clientBundle: string }>; + catalog: PluginRuntime["catalog"]; invoke(pluginId: string, method: string, input: unknown): Promise; 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 { 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 { + const manifest = await readPluginManifest(directory); + assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); + } + private async updateSource(pluginId: string): Promise { 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); diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts index 07328c95b9..fa73c48b25 100644 --- a/packages/server/src/server/plugins/manifest.test.ts +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -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); diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts index b721f395d3..b1021e21c0 100644 --- a/packages/server/src/server/plugins/manifest.ts +++ b/packages/server/src/server/plugins/manifest.ts @@ -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 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; } diff --git a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index ed28f04196..18e0ec3891 100644 --- a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts @@ -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"), diff --git a/packages/server/src/server/plugins/requirements.posix.test.ts b/packages/server/src/server/plugins/requirements.posix.test.ts new file mode 100644 index 0000000000..c0c6792bc5 --- /dev/null +++ b/packages/server/src/server/plugins/requirements.posix.test.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]); +}); diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 415c0e5374..940ca26e4c 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -88,8 +88,9 @@ function createReloadChild( function createTestRuntime( dependencies: NonNullable[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); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index e487368501..516c320c4f 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -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; providers: readonly PluginProviderMetadata[]; @@ -312,7 +315,8 @@ export class PluginRuntime { async validatePlugin(configuredPath: string): Promise { 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 { 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, diff --git a/packages/server/src/server/plugins/settings.e2e.test.ts b/packages/server/src/server/plugins/settings.e2e.test.ts index baff17d0c3..ce49b33876 100644 --- a/packages/server/src/server/plugins/settings.e2e.test.ts +++ b/packages/server/src/server/plugins/settings.e2e.test.ts @@ -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"), diff --git a/packages/server/src/server/test-utils/versioned-daemon.ts b/packages/server/src/server/test-utils/versioned-daemon.ts new file mode 100644 index 0000000000..e1b7433034 --- /dev/null +++ b/packages/server/src/server/test-utils/versioned-daemon.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); +}); diff --git a/plugin-examples/catppuccin/paseo-plugin.json b/plugin-examples/catppuccin/paseo-plugin.json index b7946e39e2..0ebf66ed4e 100644 --- a/plugin-examples/catppuccin/paseo-plugin.json +++ b/plugin-examples/catppuccin/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "catppuccin" + "id": "catppuccin", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/inline-thinking/paseo-plugin.json b/plugin-examples/inline-thinking/paseo-plugin.json index e9d5e76ab1..eade794f02 100644 --- a/plugin-examples/inline-thinking/paseo-plugin.json +++ b/plugin-examples/inline-thinking/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "inline-thinking" + "id": "inline-thinking", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/linear/paseo-plugin.json b/plugin-examples/linear/paseo-plugin.json index c8fa978d91..383edd9b25 100644 --- a/plugin-examples/linear/paseo-plugin.json +++ b/plugin-examples/linear/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "linear" + "id": "linear", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/local-plugin/paseo-plugin.json b/plugin-examples/local-plugin/paseo-plugin.json index d1a1b37435..a53074de39 100644 --- a/plugin-examples/local-plugin/paseo-plugin.json +++ b/plugin-examples/local-plugin/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "local-example" + "id": "local-example", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/modal-ui/paseo-plugin.json b/plugin-examples/modal-ui/paseo-plugin.json index b2dc64c17a..bb1d30898f 100644 --- a/plugin-examples/modal-ui/paseo-plugin.json +++ b/plugin-examples/modal-ui/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "modal-ui-example" + "id": "modal-ui-example", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/provider-acp-transformer/paseo-plugin.json b/plugin-examples/provider-acp-transformer/paseo-plugin.json index 9ef350ba02..9c3f23a174 100644 --- a/plugin-examples/provider-acp-transformer/paseo-plugin.json +++ b/plugin-examples/provider-acp-transformer/paseo-plugin.json @@ -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" + } } diff --git a/plugin-examples/provider-direct/paseo-plugin.json b/plugin-examples/provider-direct/paseo-plugin.json index e9af46c731..c4c6259d53 100644 --- a/plugin-examples/provider-direct/paseo-plugin.json +++ b/plugin-examples/provider-direct/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "provider-direct-example" + "id": "provider-direct-example", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/plugin-examples/settings/paseo-plugin.json b/plugin-examples/settings/paseo-plugin.json index a654e069da..4fae276fe8 100644 --- a/plugin-examples/settings/paseo-plugin.json +++ b/plugin-examples/settings/paseo-plugin.json @@ -1 +1,6 @@ -{ "id": "settings-example" } +{ + "id": "settings-example", + "requirements": { + "paseo": ">=0.8.0" + } +} diff --git a/plugin-examples/timeline-items/paseo-plugin.json b/plugin-examples/timeline-items/paseo-plugin.json index 8778b30780..bdcfe32470 100644 --- a/plugin-examples/timeline-items/paseo-plugin.json +++ b/plugin-examples/timeline-items/paseo-plugin.json @@ -1,3 +1,6 @@ { - "id": "pi-tasks-timeline" + "id": "pi-tasks-timeline", + "requirements": { + "paseo": ">=0.8.0" + } } diff --git a/public-docs/plugins/v0.8/index.md b/public-docs/plugins/v0.8/index.md index 7623a8b476..95571e2df8 100644 --- a/public-docs/plugins/v0.8/index.md +++ b/public-docs/plugins/v0.8/index.md @@ -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 diff --git a/public-docs/plugins/v0.8/migration.md b/public-docs/plugins/v0.8/migration.md index cad91d5537..a574958e02 100644 --- a/public-docs/plugins/v0.8/migration.md +++ b/public-docs/plugins/v0.8/migration.md @@ -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: ` | 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: diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 167e574f72..d934679768 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -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"] diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 53110b8103..69349fd90c 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -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.