feat: show plugin features from the manifest on the plugin page

This commit is contained in:
LukeGus committed 2026-10-06 22:38:13 -05:00
1 parent 60024bdd4e
commit b6bb3ffc73
13 files changed
+158 -2

No files matched your search

@@ -79,6 +79,12 @@
"pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/", "pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/",
"description": "A YouTube link shown at the top of the plugin's page." "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": { "engine": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
+46
View File
@@ -59,6 +59,26 @@ const YOUTUBE_HOSTS = new Set([
"www.youtube-nocookie.com", "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 * 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. * ever used, so a manifest can never point the embed at another site.
@@ -579,6 +599,8 @@ export interface PluginManifest {
icon?: string; icon?: string;
/** A YouTube link shown at the top of the plugin's page. */ /** A YouTube link shown at the top of the plugin's page. */
video?: string; video?: string;
/** Short lines listing what the plugin does, shown on its page. */
features?: string[];
engine: PluginEngine; engine: PluginEngine;
/** Catalog capability ids. */ /** Catalog capability ids. */
capabilities: string[]; capabilities: string[];
@@ -631,6 +653,7 @@ const ALLOWED_TOP_LEVEL = new Set([
"repository", "repository",
"icon", "icon",
"video", "video",
"features",
"dependencies", "dependencies",
"optionalDependencies", "optionalDependencies",
"provides", "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)}`, `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) { for (const field of ["backend", "frontend", "locales"] as const) {
if (field in m && requireString(m[field], field, errors)) { if (field in m && requireString(m[field], field, errors)) {
requireRelativePath(m[field] as string, field, errors); requireRelativePath(m[field] as string, field, errors);
@@ -827,6 +851,28 @@ export function validateManifest(manifest: unknown): string[] {
return errors; 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 { function validateAuthor(author: unknown, errors: string[]): void {
if (author === undefined) return; if (author === undefined) return;
if (!isPlainObject(author)) { if (!isPlainObject(author)) {
+5 -1
View File
@@ -18,7 +18,7 @@ import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js"; import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js"; import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
import { PluginManageError } from "../../plugins/manage.js"; import { PluginManageError } from "../../plugins/manage.js";
import { youtubeVideoId } from "../../plugins/manifest.js"; import { pluginFeatures, youtubeVideoId } from "../../plugins/manifest.js";
import { import {
isPluginChoice, isPluginChoice,
type PluginChoice, type PluginChoice,
@@ -257,6 +257,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let author: string | undefined; let author: string | undefined;
let repository: string | undefined; let repository: string | undefined;
let videoId: string | undefined; let videoId: string | undefined;
let features: string[] = [];
try { try {
const manifest = JSON.parse(record.manifestJson) as { const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown; contributes?: unknown;
@@ -266,6 +267,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
author?: unknown; author?: unknown;
repository?: unknown; repository?: unknown;
video?: unknown; video?: unknown;
features?: unknown;
dependencies?: Record<string, string>; dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>; optionalDependencies?: Record<string, string>;
}; };
@@ -288,6 +290,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
? manifest.repository ? manifest.repository
: undefined; : undefined;
videoId = youtubeVideoId(manifest?.video) ?? undefined; videoId = youtubeVideoId(manifest?.video) ?? undefined;
features = pluginFeatures(manifest?.features);
} catch { } catch {
contributes = null; contributes = null;
} }
@@ -318,6 +321,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
author, author,
repository, repository,
videoId, videoId,
features,
signedBy: loaded?.signedBy ?? null, signedBy: loaded?.signedBy ?? null,
capabilities, capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [], grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
+9 -1
View File
@@ -43,7 +43,11 @@ import { invalidatePluginPermissionCache } from "./permissions.js";
import { moveWithRetry } from "./fs-retry.js"; import { moveWithRetry } from "./fs-retry.js";
import { readInstallCounts } from "./install-counts.js"; import { readInstallCounts } from "./install-counts.js";
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.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 { requireSignedPlugins } from "./trust.js";
import { import {
parseChangelog, parseChangelog,
@@ -213,6 +217,7 @@ export interface RegistryListing {
repository?: string; repository?: string;
icon?: string; icon?: string;
videoId?: string; videoId?: string;
features: string[];
versions: Array<{ versions: Array<{
version: string; version: string;
compatible: boolean; compatible: boolean;
@@ -272,6 +277,7 @@ export async function listRegistry(
repository: plugin.repository, repository: plugin.repository,
icon: plugin.icon, icon: plugin.icon,
videoId: plugin.videoId, videoId: plugin.videoId,
features: plugin.features,
versions: plugin.versions.map((entry) => ({ versions: plugin.versions.map((entry) => ({
version: entry.version, version: entry.version,
compatible: isApiCompatible(entry.api), compatible: isApiCompatible(entry.api),
@@ -1156,6 +1162,7 @@ export interface UploadPreview {
version: string; version: string;
description: string; description: string;
author: string; author: string;
features: string[];
capabilities: string[]; capabilities: string[];
/** The version of an earlier upload this one replaces. */ /** The version of an earlier upload this one replaces. */
replaces: string | null; replaces: string | null;
@@ -1258,6 +1265,7 @@ export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
version: manifest.version, version: manifest.version,
description: manifest.description ?? "", description: manifest.description ?? "",
author: manifest.author?.name ?? "", author: manifest.author?.name ?? "",
features: pluginFeatures(manifest.features),
capabilities: [...manifest.capabilities], capabilities: [...manifest.capabilities],
replaces, replaces,
}; };
+1
View File
@@ -18,6 +18,7 @@ export {
RESERVED_PERMISSION_PREFIXES, RESERVED_PERMISSION_PREFIXES,
qualifyPermission, qualifyPermission,
youtubeVideoId, youtubeVideoId,
pluginFeatures,
} from "@termix-ssh/plugin-sdk/manifest"; } from "@termix-ssh/plugin-sdk/manifest";
export type { export type {
+3
View File
@@ -13,6 +13,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import semver from "semver"; import semver from "semver";
import { import {
pluginFeatures,
SUPPORTED_PLUGIN_API_VERSION, SUPPORTED_PLUGIN_API_VERSION,
youtubeVideoId, youtubeVideoId,
} from "@termix-ssh/plugin-sdk/manifest"; } from "@termix-ssh/plugin-sdk/manifest";
@@ -57,6 +58,7 @@ export interface RegistryPlugin {
icon?: string; icon?: string;
/** Only ever a YouTube video id, never a link. */ /** Only ever a YouTube video id, never a link. */
videoId?: string; videoId?: string;
features: string[];
/** Newest first. */ /** Newest first. */
versions: RegistryVersion[]; versions: RegistryVersion[];
} }
@@ -138,6 +140,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
repository: asString(entry.repository), repository: asString(entry.repository),
icon: asString(entry.icon), icon: asString(entry.icon),
videoId: youtubeVideoId(entry.video) ?? undefined, videoId: youtubeVideoId(entry.video) ?? undefined,
features: pluginFeatures(entry.features),
versions, versions,
}); });
} }
@@ -13,6 +13,7 @@ import {
SUPPORTED_PLUGIN_API_VERSION, SUPPORTED_PLUGIN_API_VERSION,
isTermixCompatible, isTermixCompatible,
youtubeVideoId, youtubeVideoId,
pluginFeatures,
} from "../../plugins/manifest.js"; } from "../../plugins/manifest.js";
function base(overrides: Record<string, unknown> = {}) { function base(overrides: Record<string, unknown> = {}) {
@@ -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", () => { describe("youtubeVideoId", () => {
it("returns only the id", () => { it("returns only the id", () => {
expect( expect(
@@ -143,6 +143,17 @@ describe("parseRegistryIndex", () => {
expect(index.plugins[1].versions[0].notes).toHaveLength(20_000); 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", () => { it("refuses something that is not an index", () => {
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/); expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
}); });
+4
View File
@@ -252,6 +252,8 @@ export interface PluginSummary {
repository?: string; repository?: string;
/** A YouTube video id from the manifest. Admins only. */ /** A YouTube video id from the manifest. Admins only. */
videoId?: string; videoId?: string;
/** What the plugin does, from the manifest. Admins only. */
features?: string[];
signedBy?: string | null; signedBy?: string | null;
} }
@@ -286,6 +288,7 @@ export interface RegistryPluginEntry {
repository?: string; repository?: string;
icon?: string; icon?: string;
videoId?: string; videoId?: string;
features?: string[];
versions: RegistryPluginVersion[]; versions: RegistryPluginVersion[];
latestVersion: string | null; latestVersion: string | null;
installed: boolean; installed: boolean;
@@ -499,6 +502,7 @@ export interface PluginUploadPreview {
version: string; version: string;
description: string; description: string;
author: string; author: string;
features?: string[];
capabilities: string[]; capabilities: string[];
replaces: string | null; replaces: string | null;
} }
+1
View File
@@ -2122,6 +2122,7 @@
}, },
"installedBadge": "Installed", "installedBadge": "Installed",
"incompatible": "Needs a newer Termix", "incompatible": "Needs a newer Termix",
"features": "Features",
"whatItCanDo": "What it can do", "whatItCanDo": "What it can do",
"addsToTermix": "Adds to Termix", "addsToTermix": "Adds to Termix",
"addsToTermixHint": "Uninstall and every one of these goes away.", "addsToTermixHint": "Uninstall and every one of these goes away.",
+17
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Bug, Bug,
Check,
Database, Database,
Download, Download,
ExternalLink, ExternalLink,
@@ -203,6 +204,22 @@ export function PluginDetail({
<div className="flex flex-col gap-2 lg:col-span-2"> <div className="flex flex-col gap-2 lg:col-span-2">
<PluginVideo videoId={plugin.videoId} name={plugin.name} /> <PluginVideo videoId={plugin.videoId} name={plugin.name} />
{plugin.features.length > 0 && (
<SectionCard title={t("plugins.manager.features")} icon={null}>
<ul className="grid grid-cols-1 gap-x-4 gap-y-1.5 px-4 py-3 sm:grid-cols-2">
{plugin.features.map((feature) => (
<li
key={feature}
className="flex items-start gap-2 text-xs leading-snug"
>
<Check className="mt-px size-3.5 shrink-0 text-accent-brand" />
<span>{feature}</span>
</li>
))}
</ul>
</SectionCard>
)}
<SectionCard title={t("plugins.manager.whatItCanDo")} icon={null}> <SectionCard title={t("plugins.manager.whatItCanDo")} icon={null}>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{orderByRisk(plugin.capabilities).map((capability) => ( {orderByRisk(plugin.capabilities).map((capability) => (
+7
View File
@@ -34,6 +34,7 @@ export interface PluginEntry {
icon?: string; icon?: string;
repository?: string; repository?: string;
videoId?: string; videoId?: string;
features: string[];
installed: boolean; installed: boolean;
version: string | null; version: string | null;
latestVersion: string | null; latestVersion: string | null;
@@ -94,6 +95,9 @@ export function mergePlugins(
icon: summary.icon ?? entry?.icon, icon: summary.icon ?? entry?.icon,
repository: summary.repository ?? entry?.repository, repository: summary.repository ?? entry?.repository,
videoId: summary.videoId ?? entry?.videoId, videoId: summary.videoId ?? entry?.videoId,
features: summary.features?.length
? summary.features
: (entry?.features ?? []),
installed: true, installed: true,
version: summary.version, version: summary.version,
latestVersion: entry?.latestVersion ?? null, latestVersion: entry?.latestVersion ?? null,
@@ -130,6 +134,7 @@ export function mergePlugins(
icon: entry.icon, icon: entry.icon,
repository: entry.repository, repository: entry.repository,
videoId: entry.videoId, videoId: entry.videoId,
features: entry.features ?? [],
installed: false, installed: false,
version: null, version: null,
latestVersion: entry.latestVersion, latestVersion: entry.latestVersion,
@@ -163,6 +168,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
name: preview.name, name: preview.name,
description: preview.description, description: preview.description,
author: preview.author, author: preview.author,
features: preview.features ?? [],
category: "", category: "",
installed: preview.replaces !== null, installed: preview.replaces !== null,
version: preview.replaces, version: preview.replaces,
@@ -226,6 +232,7 @@ export function matchesQuery(row: PluginEntry, query: string): boolean {
return ( return (
row.name.toLowerCase().includes(q) || row.name.toLowerCase().includes(q) ||
row.description.toLowerCase().includes(q) || row.description.toLowerCase().includes(q) ||
row.features.some((f) => f.toLowerCase().includes(q)) ||
row.id.includes(q) row.id.includes(q)
); );
} }
+16
View File
@@ -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", () => { it("keeps installed plugins when the registry is unreachable", () => {
const rows = mergePlugins([summary()], null); const rows = mergePlugins([summary()], null);
expect(rows).toHaveLength(1); expect(rows).toHaveLength(1);
@@ -120,6 +132,7 @@ describe("sorting and filtering", () => {
name: "Alpha", name: "Alpha",
category: "Access", category: "Access",
description: "Sign in", description: "Sign in",
features: ["Works with Podman"],
versions: [ versions: [
{ {
version: "1.0.0", version: "1.0.0",
@@ -143,6 +156,9 @@ describe("sorting and filtering", () => {
expect( expect(
rows.filter((r) => matchesQuery(r, "sign")).map((r) => r.id), rows.filter((r) => matchesQuery(r, "sign")).map((r) => r.id),
).toEqual(["a"]); ).toEqual(["a"]);
expect(
rows.filter((r) => matchesQuery(r, "podman")).map((r) => r.id),
).toEqual(["a"]);
expect(rows.filter((r) => matchesQuery(r, " ")).length).toBe(2); expect(rows.filter((r) => matchesQuery(r, " ")).length).toBe(2);
}); });