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
This commit is contained in:
Mohamed Boudra
2026-10-06 23:18:24 +01:00
committed by GitHub
parent 1463396ebc
commit 2d85fe4e04
14 changed files with 224 additions and 38 deletions
@@ -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();
});
+6 -1
View File
@@ -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);
@@ -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);
+27 -8
View File
@@ -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<typeof PluginManifestSchema>;
@@ -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 () => {