diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index 220d1611a..74be31590 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -79,6 +79,12 @@ "pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/", "description": "A YouTube link shown at the top of the plugin's page." }, + "features": { + "type": "array", + "maxItems": 20, + "items": { "type": "string", "minLength": 1, "maxLength": 160 }, + "description": "Short lines listing what the plugin does, shown on its page." + }, "engine": { "type": "object", "additionalProperties": false, diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index aa94b6e03..4dae63705 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -59,6 +59,26 @@ const YOUTUBE_HOSTS = new Set([ "www.youtube-nocookie.com", ]); +export const MAX_FEATURES = 20; +export const MAX_FEATURE_LENGTH = 160; + +/** + * The usable feature lines of a manifest or registry entry. Anything that is + * not a short non-empty string is dropped, so an index cannot flood the page. + */ +export function pluginFeatures(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter( + (f): f is string => + typeof f === "string" && + f.trim().length > 0 && + f.length <= MAX_FEATURE_LENGTH, + ) + .map((f) => f.trim()) + .slice(0, MAX_FEATURES); +} + /** * The video id of a YouTube link, or null for anything else. Only the id is * ever used, so a manifest can never point the embed at another site. @@ -579,6 +599,8 @@ export interface PluginManifest { icon?: string; /** A YouTube link shown at the top of the plugin's page. */ video?: string; + /** Short lines listing what the plugin does, shown on its page. */ + features?: string[]; engine: PluginEngine; /** Catalog capability ids. */ capabilities: string[]; @@ -631,6 +653,7 @@ const ALLOWED_TOP_LEVEL = new Set([ "repository", "icon", "video", + "features", "dependencies", "optionalDependencies", "provides", @@ -792,6 +815,7 @@ export function validateManifest(manifest: unknown): string[] { `Field "video" must be a YouTube link such as https://www.youtube.com/watch?v=..., got: ${JSON.stringify(m.video)}`, ); } + validateFeatures(m.features, errors); for (const field of ["backend", "frontend", "locales"] as const) { if (field in m && requireString(m[field], field, errors)) { requireRelativePath(m[field] as string, field, errors); @@ -827,6 +851,28 @@ export function validateManifest(manifest: unknown): string[] { return errors; } +function validateFeatures(features: unknown, errors: string[]): void { + if (features === undefined) return; + if (!Array.isArray(features)) { + errors.push('Field "features" must be an array of strings'); + return; + } + if (features.length > MAX_FEATURES) { + errors.push(`Field "features" may list at most ${MAX_FEATURES} items`); + } + features.forEach((feature, i) => { + if ( + typeof feature !== "string" || + feature.trim().length === 0 || + feature.length > MAX_FEATURE_LENGTH + ) { + errors.push( + `Field "features[${i}]" must be a non-empty string of at most ${MAX_FEATURE_LENGTH} characters`, + ); + } + }); +} + function validateAuthor(author: unknown, errors: string[]): void { if (author === undefined) return; if (!isPlainObject(author)) { diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 2d7901b36..d56f03b58 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -18,7 +18,7 @@ import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; import { getPluginPublicHttpRoutes } from "../../plugins/http.js"; import { getPluginPublicWsRoutes } from "../../plugins/ws.js"; import { PluginManageError } from "../../plugins/manage.js"; -import { youtubeVideoId } from "../../plugins/manifest.js"; +import { pluginFeatures, youtubeVideoId } from "../../plugins/manifest.js"; import { isPluginChoice, type PluginChoice, @@ -257,6 +257,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { let author: string | undefined; let repository: string | undefined; let videoId: string | undefined; + let features: string[] = []; try { const manifest = JSON.parse(record.manifestJson) as { contributes?: unknown; @@ -266,6 +267,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { author?: unknown; repository?: unknown; video?: unknown; + features?: unknown; dependencies?: Record; optionalDependencies?: Record; }; @@ -288,6 +290,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { ? manifest.repository : undefined; videoId = youtubeVideoId(manifest?.video) ?? undefined; + features = pluginFeatures(manifest?.features); } catch { contributes = null; } @@ -318,6 +321,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { author, repository, videoId, + features, signedBy: loaded?.signedBy ?? null, capabilities, grantedCapabilities: grantsByPlugin.get(record.id) ?? [], diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index cedc0200e..ff819448a 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -43,7 +43,11 @@ import { invalidatePluginPermissionCache } from "./permissions.js"; import { moveWithRetry } from "./fs-retry.js"; import { readInstallCounts } from "./install-counts.js"; import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js"; -import { parseManifest, type PluginManifest } from "./manifest.js"; +import { + parseManifest, + pluginFeatures, + type PluginManifest, +} from "./manifest.js"; import { requireSignedPlugins } from "./trust.js"; import { parseChangelog, @@ -213,6 +217,7 @@ export interface RegistryListing { repository?: string; icon?: string; videoId?: string; + features: string[]; versions: Array<{ version: string; compatible: boolean; @@ -272,6 +277,7 @@ export async function listRegistry( repository: plugin.repository, icon: plugin.icon, videoId: plugin.videoId, + features: plugin.features, versions: plugin.versions.map((entry) => ({ version: entry.version, compatible: isApiCompatible(entry.api), @@ -1156,6 +1162,7 @@ export interface UploadPreview { version: string; description: string; author: string; + features: string[]; capabilities: string[]; /** The version of an earlier upload this one replaces. */ replaces: string | null; @@ -1258,6 +1265,7 @@ export async function stageUpload(buffer: Buffer): Promise { version: manifest.version, description: manifest.description ?? "", author: manifest.author?.name ?? "", + features: pluginFeatures(manifest.features), capabilities: [...manifest.capabilities], replaces, }; diff --git a/src/backend/plugins/manifest.ts b/src/backend/plugins/manifest.ts index e1fe84ce4..be9d53e93 100644 --- a/src/backend/plugins/manifest.ts +++ b/src/backend/plugins/manifest.ts @@ -18,6 +18,7 @@ export { RESERVED_PERMISSION_PREFIXES, qualifyPermission, youtubeVideoId, + pluginFeatures, } from "@termix-ssh/plugin-sdk/manifest"; export type { diff --git a/src/backend/plugins/registry-index.ts b/src/backend/plugins/registry-index.ts index 3d36f3203..70a1bcf1f 100644 --- a/src/backend/plugins/registry-index.ts +++ b/src/backend/plugins/registry-index.ts @@ -13,6 +13,7 @@ import fs from "node:fs"; import path from "node:path"; import semver from "semver"; import { + pluginFeatures, SUPPORTED_PLUGIN_API_VERSION, youtubeVideoId, } from "@termix-ssh/plugin-sdk/manifest"; @@ -57,6 +58,7 @@ export interface RegistryPlugin { icon?: string; /** Only ever a YouTube video id, never a link. */ videoId?: string; + features: string[]; /** Newest first. */ versions: RegistryVersion[]; } @@ -138,6 +140,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex { repository: asString(entry.repository), icon: asString(entry.icon), videoId: youtubeVideoId(entry.video) ?? undefined, + features: pluginFeatures(entry.features), versions, }); } diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts index cb99679b6..4e567bc1e 100644 --- a/src/backend/tests/plugins/manifest.test.ts +++ b/src/backend/tests/plugins/manifest.test.ts @@ -13,6 +13,7 @@ import { SUPPORTED_PLUGIN_API_VERSION, isTermixCompatible, youtubeVideoId, + pluginFeatures, } from "../../plugins/manifest.js"; function base(overrides: Record = {}) { @@ -885,6 +886,37 @@ describe("manifest video", () => { }); }); +describe("manifest features", () => { + it("accepts a short list of lines", () => { + expect( + validateManifest(base({ features: ["Start containers", "Read logs"] })), + ).toEqual([]); + }); + + it("rejects bad lists", () => { + expect(validateManifest(base({ features: "Logs" }))).toEqual([ + 'Field "features" must be an array of strings', + ]); + expect(validateManifest(base({ features: ["ok", "", 4] }))).toHaveLength(2); + expect( + validateManifest(base({ features: ["x".repeat(161)] })).join(), + ).toMatch(/features\[0\]/); + expect( + validateManifest(base({ features: Array(21).fill("x") })).join(), + ).toMatch(/at most 20/); + }); +}); + +describe("pluginFeatures", () => { + it("keeps only usable lines", () => { + expect(pluginFeatures([" Logs ", "", 3, "x".repeat(161), "Stats"])).toEqual( + ["Logs", "Stats"], + ); + expect(pluginFeatures(Array(30).fill("x"))).toHaveLength(20); + expect(pluginFeatures("Logs")).toEqual([]); + }); +}); + describe("youtubeVideoId", () => { it("returns only the id", () => { expect( diff --git a/src/backend/tests/plugins/registry-index.test.ts b/src/backend/tests/plugins/registry-index.test.ts index b3dae7566..428016c54 100644 --- a/src/backend/tests/plugins/registry-index.test.ts +++ b/src/backend/tests/plugins/registry-index.test.ts @@ -143,6 +143,17 @@ describe("parseRegistryIndex", () => { expect(index.plugins[1].versions[0].notes).toHaveLength(20_000); }); + it("keeps only usable feature lines", () => { + const index = parseRegistryIndex({ + plugins: [ + { id: "docker", features: ["Logs", 5, ""], versions: [version()] }, + { id: "other", features: "Logs", versions: [version()] }, + ], + }); + expect(index.plugins[0].features).toEqual(["Logs"]); + expect(index.plugins[1].features).toEqual([]); + }); + it("refuses something that is not an index", () => { expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/); }); diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 0d588b53f..173d4e2c2 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -252,6 +252,8 @@ export interface PluginSummary { repository?: string; /** A YouTube video id from the manifest. Admins only. */ videoId?: string; + /** What the plugin does, from the manifest. Admins only. */ + features?: string[]; signedBy?: string | null; } @@ -286,6 +288,7 @@ export interface RegistryPluginEntry { repository?: string; icon?: string; videoId?: string; + features?: string[]; versions: RegistryPluginVersion[]; latestVersion: string | null; installed: boolean; @@ -499,6 +502,7 @@ export interface PluginUploadPreview { version: string; description: string; author: string; + features?: string[]; capabilities: string[]; replaces: string | null; } diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index fa9333c9e..50c3e0f3a 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2122,6 +2122,7 @@ }, "installedBadge": "Installed", "incompatible": "Needs a newer Termix", + "features": "Features", "whatItCanDo": "What it can do", "addsToTermix": "Adds to Termix", "addsToTermixHint": "Uninstall and every one of these goes away.", diff --git a/src/ui/plugins/PluginDetail.tsx b/src/ui/plugins/PluginDetail.tsx index dcc5606ba..3634bb9a0 100644 --- a/src/ui/plugins/PluginDetail.tsx +++ b/src/ui/plugins/PluginDetail.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Bug, + Check, Database, Download, ExternalLink, @@ -203,6 +204,22 @@ export function PluginDetail({
+ {plugin.features.length > 0 && ( + +
    + {plugin.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ )} +
{orderByRisk(plugin.capabilities).map((capability) => ( diff --git a/src/ui/plugins/plugin-model.ts b/src/ui/plugins/plugin-model.ts index 0ca8f1a42..7aa58a300 100644 --- a/src/ui/plugins/plugin-model.ts +++ b/src/ui/plugins/plugin-model.ts @@ -34,6 +34,7 @@ export interface PluginEntry { icon?: string; repository?: string; videoId?: string; + features: string[]; installed: boolean; version: string | null; latestVersion: string | null; @@ -94,6 +95,9 @@ export function mergePlugins( icon: summary.icon ?? entry?.icon, repository: summary.repository ?? entry?.repository, videoId: summary.videoId ?? entry?.videoId, + features: summary.features?.length + ? summary.features + : (entry?.features ?? []), installed: true, version: summary.version, latestVersion: entry?.latestVersion ?? null, @@ -130,6 +134,7 @@ export function mergePlugins( icon: entry.icon, repository: entry.repository, videoId: entry.videoId, + features: entry.features ?? [], installed: false, version: null, latestVersion: entry.latestVersion, @@ -163,6 +168,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry { name: preview.name, description: preview.description, author: preview.author, + features: preview.features ?? [], category: "", installed: preview.replaces !== null, version: preview.replaces, @@ -226,6 +232,7 @@ export function matchesQuery(row: PluginEntry, query: string): boolean { return ( row.name.toLowerCase().includes(q) || row.description.toLowerCase().includes(q) || + row.features.some((f) => f.toLowerCase().includes(q)) || row.id.includes(q) ); } diff --git a/src/ui/tests/plugins/plugin-model.test.ts b/src/ui/tests/plugins/plugin-model.test.ts index 611f26979..7d2ec35d2 100644 --- a/src/ui/tests/plugins/plugin-model.test.ts +++ b/src/ui/tests/plugins/plugin-model.test.ts @@ -92,6 +92,18 @@ describe("mergePlugins", () => { }); }); + it("uses the installed features and falls back to the registry", () => { + const registry = entry({ features: ["From registry"] }); + expect( + mergePlugins([summary({ features: ["Installed"] })], [registry])[0] + .features, + ).toEqual(["Installed"]); + expect(mergePlugins([summary()], [registry])[0].features).toEqual([ + "From registry", + ]); + expect(mergePlugins([summary()], null)[0].features).toEqual([]); + }); + it("keeps installed plugins when the registry is unreachable", () => { const rows = mergePlugins([summary()], null); expect(rows).toHaveLength(1); @@ -120,6 +132,7 @@ describe("sorting and filtering", () => { name: "Alpha", category: "Access", description: "Sign in", + features: ["Works with Podman"], versions: [ { version: "1.0.0", @@ -143,6 +156,9 @@ describe("sorting and filtering", () => { expect( rows.filter((r) => matchesQuery(r, "sign")).map((r) => r.id), ).toEqual(["a"]); + expect( + rows.filter((r) => matchesQuery(r, "podman")).map((r) => r.id), + ).toEqual(["a"]); expect(rows.filter((r) => matchesQuery(r, " ")).length).toBe(2); });