mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
@@ -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}` },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -206,7 +206,17 @@ export async function scaffoldPluginDirectory(
|
||||
const files = new Map<string, string>([
|
||||
[
|
||||
"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`],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -126,9 +126,7 @@ function PluginsPage() {
|
||||
<PluginSectionHeader>
|
||||
<div>
|
||||
<PluginSectionTitle id="featured">Featured</PluginSectionTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A selection of hand picked plugins
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">A selection of hand picked plugins</p>
|
||||
</div>
|
||||
<a
|
||||
href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })}
|
||||
|
||||
@@ -152,6 +152,39 @@ In the app, enter only the source identifier.
|
||||
|
||||
:::
|
||||
|
||||
## Icons and screenshots
|
||||
|
||||
Declare your display name, icon, screenshots, and demo videos in `paseo-plugin.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "review-tools",
|
||||
"name": "Review tools",
|
||||
"description": "Reviews changes before merge",
|
||||
"icon": "assets/icon.png",
|
||||
"media": ["assets/screenshot.png", "https://example.com/review-demo.mp4"],
|
||||
"requirements": { "paseo": ">=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/<owner>/<slug>.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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/<owner>/<slug>.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.
|
||||
|
||||
Reference in New Issue
Block a user