From 2d85fe4e04268f845ea4153e66e7e6d37f4e4963 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 23:18:24 +0100 Subject: [PATCH] feat(plugins): declare display metadata in plugin manifests (#6263) * feat(plugins): declare display metadata in plugin manifests * style(website): format plugin page description * fix(plugins): preserve default names and clarify registry follow-up * test: fix stale plugin card selectors and terminal marker race --- .../cli/src/commands/plugin/scaffold.test.ts | 2 + packages/cli/src/commands/plugin/scaffold.ts | 12 +++- .../protocol/src/messages.plugins.test.ts | 21 +++++++ packages/protocol/src/messages.ts | 3 + .../src/server/plugins/index.posix.test.ts | 26 ++++++-- packages/server/src/server/plugins/index.ts | 7 ++- .../src/server/plugins/manifest.test.ts | 63 +++++++++++++++++++ .../server/src/server/plugins/manifest.ts | 35 ++++++++--- .../terminal/worker-terminal-manager.test.ts | 4 +- packages/website/e2e/plugins.spec.ts | 22 ++++--- packages/website/src/routes/plugins/index.tsx | 4 +- public-docs/plugins/publishing.md | 36 ++++++++++- public-docs/plugins/reference.md | 24 +++++-- skills/paseo-plugin/SKILL.md | 3 +- 14 files changed, 224 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index c69ca4c197..a3350ecb8c 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -93,6 +93,8 @@ describe("plugin scaffold", () => { expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual( { id: "hello-plugin", + $comment: expect.stringContaining('"icon": "assets/icon.png"'), + media: [], requirements: { paseo: `>=${cliPackageJson.version}` }, }, ); diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index f0f1fe3b93..b1eb53a6ef 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -206,7 +206,17 @@ export async function scaffoldPluginDirectory( const files = new Map([ [ "paseo-plugin.json", - `${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`, + `${JSON.stringify( + { + id, + $comment: + 'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.', + media: [], + requirements: { paseo: `>=${version}` }, + }, + null, + 2, + )}\n`, ], ["OVERVIEW.md", OVERVIEW], ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index 5c62e90669..f266b65988 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => { expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); }, ); + it("preserves manifest metadata in plugin list responses", () => { + const message = { + type: "plugin.list.response", + payload: { + requestId: "metadata", + plugins: [ + { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + path: "/plugins/review", + enabled: false, + status: "disabled", + }, + ], + }, + }; + 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 41b9ca3f74..6f5e4abd3f 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6632,6 +6632,9 @@ export const PluginNpmInstallationSchema = z.object({ export const PluginListItemSchema = z.object({ id: PluginIdSchema, + name: z.string().optional(), + icon: z.string().optional(), + media: z.array(z.string()).optional(), description: z.string().optional(), path: z.string(), enabled: z.boolean(), diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index f9592597f1..71fe2b96e9 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -419,7 +419,7 @@ describe("PluginService", () => { await service.stopAllPlugins(); }, 20_000); - it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => { + it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); const running = await createPlugin("running", "export default () => () => {};"); @@ -427,7 +427,13 @@ describe("PluginService", () => { const malformed = await createPlugin("malformed", "export default () => () => {};"); await writeFile( path.join(running, "paseo-plugin.json"), - JSON.stringify({ id: "running", description: "Runs checks" }), + JSON.stringify({ + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }), ); await writeFile( path.join(disabled, "paseo-plugin.json"), @@ -443,11 +449,23 @@ describe("PluginService", () => { await service.start(); expect( - (await service.listPlugins()).map(({ id, description }) => ({ id, description })), + (await service.listPlugins()).map(({ id, description, name, icon, media }) => ({ + id, + description, + name, + icon, + media, + })), ).toEqual([ { id: "disabled", description: "Waits until enabled" }, { id: "malformed", description: undefined }, - { id: "running", description: "Runs checks" }, + { + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }, ]); await service.stopAllPlugins(); }); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 5567e23dd5..6c4af29e62 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -216,7 +216,12 @@ export class PluginService { }), }; const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null); - if (manifest?.description) item.description = manifest.description; + if (manifest) { + item.name = manifest.name; + item.description = manifest.description; + item.icon = manifest.icon; + item.media = manifest.media; + } item.installation = await this.managedSources ?.describe(id, source.path) .catch(() => undefined); diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts index 3ff06e2c3e..399e350493 100644 --- a/packages/server/src/server/plugins/manifest.test.ts +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -24,6 +24,69 @@ describe("plugin manifest", () => { }); }); + it("reads display metadata and ignores future top-level fields", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + const manifest = { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["assets/screenshot.webp", "demo.mp4", "https://example.com/media?id=1"], + }; + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ ...manifest, futureField: { enabled: true } }), + ); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + }); + + it.each([ + { name: " " }, + { name: 42 }, + { icon: "" }, + { icon: 42 }, + { icon: "icon.svg" }, + { icon: "/icon.png" }, + { icon: "../icon.png" }, + { icon: "assets/../../icon.png" }, + { icon: "https://example.com/icon.png" }, + { icon: "C:/icon.png" }, + { icon: "assets\\icon.png" }, + { icon: "icon.png?query" }, + { media: "screenshot.png" }, + { media: [42] }, + { media: [""] }, + { media: ["../demo.mp4"] }, + { media: ["/demo.mp4"] }, + { media: ["http://example.com/demo.mp4"] }, + { media: ["https://"] }, + { media: ["//example.com/demo.mp4"] }, + { media: ["file:///demo.mp4"] }, + { media: ["data:image/png;base64,abc"] }, + { media: ["."] }, + { media: ["assets/"] }, + { id: "INVALID" }, + { description: 42 }, + { requirements: { paseoo: ">=0.11.0" } }, + ])("rejects invalid known fields: %j", async (fields) => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "example", futureField: true, ...fields }), + ); + await expect(readPluginManifest(directory)).rejects.toThrow(); + }); + + it("accepts omitted metadata and empty media", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + for (const manifest of [{ id: "example" }, { id: "example", media: [] }]) { + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest)); + await expect(readPluginManifest(directory)).resolves.toEqual(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); diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts index d2e13d614b..2312a2cb5a 100644 --- a/packages/server/src/server/plugins/manifest.ts +++ b/packages/server/src/server/plugins/manifest.ts @@ -8,14 +8,33 @@ const MANIFEST_FILENAME = "paseo-plugin.json"; const PluginBuildCommandSchema = z .array(z.string().refine((argument) => argument.trim().length > 0)) .min(1); -const PluginManifestSchema = z - .object({ - id: PluginIdSchema, - description: z.string().trim().min(1).optional(), - requirements: PluginRequirementsSchema.strict().optional(), - build: z.array(PluginBuildCommandSchema).min(1).optional(), - }) - .strict(); +// Manifest asset paths use forward slashes and stay inside the plugin package. +const PluginAssetPathSchema = z + .string() + .min(1) + .refine( + (value) => + value === value.trim() && + !value.startsWith("/") && + !/[\\:?#\p{Cc}]/u.test(value) && + !value.split("/").includes("..") && + !value.endsWith("/") && + value.split("/").at(-1) !== ".", + "Expected a relative file path inside the plugin package", + ); +const PluginManifestSchema = z.object({ + id: PluginIdSchema, + name: z.string().trim().min(1).optional(), + description: z.string().trim().min(1).optional(), + icon: PluginAssetPathSchema.refine( + (value) => /\.png$/i.test(value), + "Expected a relative path to a PNG", + ).optional(), + media: z.array(z.union([PluginAssetPathSchema, z.url({ protocol: /^https$/ })])).optional(), + // A misspelled requirement must not silently disable compatibility checks. + requirements: PluginRequirementsSchema.strict().optional(), + build: z.array(PluginBuildCommandSchema).min(1).optional(), +}); export type PluginManifest = z.infer; diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 2dc999c0c3..efccd3dfb6 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -510,9 +510,7 @@ it("starts the default shell through the worker and accepts quoted commands", as session.send({ type: "input", data: `${command}\r` }); - await waitForCondition(() => existsSync(markerPath), 10000); - - expect(readFileSync(markerPath, "utf8")).toBe("shell-ok"); + await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok"); }); it("lists subdirectory terminals when querying the workspace root", async () => { diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 9d2b3af8aa..3d25f942b9 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -8,6 +8,13 @@ async function openPlugins(page: Page) { await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); } +function pluginCards(page: Page, section: string): Locator { + return page + .getByRole("region", { name: section }) + .getByRole("link") + .filter({ hasNotText: /^See all$/ }); +} + test("browses from the directory into a category, a plugin, and its author", async ({ page, context, @@ -508,11 +515,7 @@ test.describe("registry fixture layout", () => { await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured"); const featured = page.getByRole("region", { name: "Featured" }); await expect(featured).toContainText("A selection of hand picked plugins"); - await expect(featured.getByRole("link", { name: /Added/ })).toHaveText([ - /Dracula/, - /Herald/, - /launchd Jobs/, - ]); + await expect(pluginCards(page, "Featured")).toHaveText([/Dracula/, /Herald/, /launchd Jobs/]); }); test("lists the nine categories in order with counts, and the newest plugins first", async ({ @@ -524,9 +527,12 @@ test.describe("registry fixture layout", () => { CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)), ); await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0"); - await expect( - page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }), - ).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]); + await expect(pluginCards(page, "What’s new")).toHaveText([ + /Base2Tone/, + /Sayr/, + /PromptKit/, + /Defer/, + ]); }); }); diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index 08c31705de..505b8177fc 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -126,9 +126,7 @@ function PluginsPage() {
Featured -

- A selection of hand picked plugins -

+

A selection of hand picked plugins

=0.11.0" } +} +``` + +All three fields are optional. `name` gives the registry and website a display name without +changing the installation ID. When omitted, the registry falls back to a humanized ID. +Registry and website display requires the registry builder to read these fields from the pinned +manifest. That update is a separate follow-up; until it lands, these fields are exposed only +through the daemon's installed-plugin list. +`icon` must point to a PNG inside the plugin package. `media` accepts paths to images and videos +inside the package or HTTPS URLs, in display order. Paths are relative to the manifest; use +forward slashes and keep them inside the package. See the [manifest reference](/docs/plugins/reference#project-files) +for validation rules. + +Commit local assets alongside the manifest at the pinned revision. For npm, add their directory +(for example, `assets/`) to `package.json`'s `files` list and verify it with `npm pack --dry-run`. +The scaffold documents these fields in its manifest's `$comment`; add your display name and +assets before publishing. + +Manifests using these fields fail to install on daemons before 0.11.0. Set +`requirements.paseo` to `>=0.11.0` or a narrower supported range. + ## Your listing page Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo. @@ -170,8 +203,7 @@ The repository overview takes precedence over a registry import stopgap. Online fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records that already carry `plugins//.md` in the registry repository. Every version bump requires a repository overview and removes the stopgap in the same PR. A bump without the -repository overview fails validation. Neither README files nor a `paseo-listing.json` -`readme` override supplies the overview. +repository overview fails validation. README files do not supply the overview. Author overviews and registry stopgaps follow the same content contract, in this order. Choose headings only when they help; length follows complexity. A theme needs one paragraph. diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 2a8714e4d7..1a1440ad97 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -52,12 +52,24 @@ The required root manifest is `paseo-plugin.json`: } ``` -| Field | Required | Behavior | -| -------------- | -------- | ---------------------------------------------------------------------- | -| `id` | Yes | Default installation ID. | -| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | -| `requirements` | No | Supported Paseo versions, described below. | -| `build` | No | Preparation commands, described in the CLI reference. | +| Field | Required | Behavior | +| -------------- | -------- | ----------------------------------------------------------------------------------------- | +| `id` | Yes | Default installation ID. | +| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. | +| `icon` | No | Relative path to a PNG inside the plugin package. | +| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. | +| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | +| `requirements` | No | Supported Paseo versions, described below. | +| `build` | No | Preparation commands, described in the CLI reference. | + +Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..` +segments. See [icons and screenshots](/docs/plugins/publishing#icons-and-screenshots) for an example. +Paseo validates the references without opening local assets or fetching URLs. + +Unknown top-level fields are ignored. Known fields still validate, and unknown keys inside +`requirements` are rejected so a misspelled constraint cannot silently skip a compatibility check. +Manifests using `name`, `icon`, or `media` require Paseo 0.11.0 or later; older daemons reject +these fields during installation. ### Requirements diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index f229bf7a3a..e511a7e12f 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -657,8 +657,7 @@ The repository overview takes precedence over a registry import stopgap. Online fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records that already carry `plugins//.md` in the registry repository. Every version bump requires a repository overview and removes the stopgap in the same PR. A bump without the -repository overview fails validation. Neither README files nor a `paseo-listing.json` -`readme` override supplies the overview. +repository overview fails validation. README files do not supply the overview. Author overviews and registry stopgaps follow the same content contract, in this order. Choose headings only when they help; length follows complexity. A theme needs one paragraph.