feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3

This commit is contained in:
LukeGus committed 2026-10-08 00:34:50 -05:00
1 parent 7fcda3bb01
commit cfab6f764c
65 files changed
+1665 -294

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
/**
* Docs links and env var entries from a manifest. Kept apart from manifest.ts
* so the browser can use them without pulling in semver.
*/
/**
* A docs link, or null for anything that is not an https URL. Core only ever
* opens it in a new tab, but a javascript: or file: link should never get there.
*/
export function pluginDocsUrl(value: unknown): string | null {
if (typeof value !== "string") return null;
try {
const url = new URL(value.trim());
if (url.protocol !== "https:") return null;
return url.toString().replace(/\/$/, "");
} catch {
return null;
}
}
/** Joins a page and anchor onto a plugin's docs URL. */
export function pluginDocsPage(
base: string,
page?: string,
anchor?: string,
): string {
const clean = (page ?? "").replace(/^\/+|\/+$/g, "");
const url = clean ? `${base}/${clean}` : base;
return anchor ? `${url}#${anchor.replace(/^#/, "")}` : url;
}
export const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
export const MAX_ENV_DESCRIPTION_LENGTH = 300;
/**
* An environment variable a plugin reads. Only shown in docs and on the
* plugin's page, so people know what they can set.
*/
export interface PluginEnvVar {
name: string;
description: string;
/** The value used when it is not set, as written in a compose file. */
default?: string;
required?: boolean;
/** Holds a password or key. */
secret?: boolean;
}
/** The usable env entries of a manifest. Bad ones are dropped. */
export function pluginEnvVars(value: unknown): PluginEnvVar[] {
if (!Array.isArray(value)) return [];
return value.filter(
(v): v is PluginEnvVar =>
typeof v === "object" &&
v !== null &&
typeof v.name === "string" &&
ENV_NAME_PATTERN.test(v.name) &&
typeof v.description === "string",
);
}
+26
View File
@@ -888,6 +888,16 @@ export interface DesktopApi {
onRemoteServerChange: (listener: () => void) => Disposer;
}
/** Links into this plugin's docs, from the manifest's docs field. */
export interface DocsApi {
/** The docs link, or null when the manifest has none. */
readonly url: string | null;
/** A page of the docs ("setup"), with an optional anchor. */
page: (page?: string, anchor?: string) => string | null;
/** Opens a page in a new tab. Does nothing without a docs link. */
open: (page?: string, anchor?: string) => void;
}
export interface TermixAppInfo {
readonly pluginId: string;
readonly manifest: PluginManifest;
@@ -1017,6 +1027,8 @@ export interface TermixApp extends TermixAppInfo {
confirm: (options: ConfirmRequest) => Promise<boolean>;
/** The desktop app, when the frontend runs in it. */
desktop: DesktopApi;
/** This plugin's docs. Needs plugin API 1.2. */
docs: DocsApi;
/**
* Fires after this plugin's settings are saved from the settings screen or
* the host editor. Disposed automatically.
@@ -1091,6 +1103,11 @@ export interface SshAuthTypeInfo {
*/
export interface PluginHostBridge {
usePluginId: () => string;
useDocsUrl: (
pluginId: string,
page?: string,
anchor?: string,
) => string | null;
useTranslation: (pluginId: string) => {
t: TranslateFn;
language: string;
@@ -1290,6 +1307,15 @@ export function usePluginId(): string {
return requireHost().usePluginId();
}
/**
* A page of this plugin's docs, or null when the manifest has no docs link.
* Pass it to PanelShell's docs prop or use DocsLink. Needs plugin API 1.2.
*/
export function useDocsUrl(page?: string, anchor?: string): string | null {
const bridge = requireHost();
return bridge.useDocsUrl(bridge.usePluginId(), page, anchor);
}
/**
* Translation bound to this plugin's namespace. Keys the plugin does not
* define fall back to core's shared strings (common.*, hosts.*).
+76 -1
View File
@@ -12,6 +12,21 @@
import semver from "semver";
import { isKnownCapability } from "./capabilities.js";
import {
ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH,
pluginDocsUrl,
type PluginEnvVar,
} from "./docs.js";
export {
ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH,
pluginDocsPage,
pluginDocsUrl,
pluginEnvVars,
type PluginEnvVar,
} from "./docs.js";
/**
* The plugin API this build implements. A minor bump adds to the API and
@@ -21,7 +36,7 @@ import { isKnownCapability } from "./capabilities.js";
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.1.0";
export const PLUGIN_API_VERSION = "1.2.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -601,6 +616,10 @@ export interface PluginManifest {
video?: string;
/** Short lines listing what the plugin does, shown on its page. */
features?: string[];
/** Where the plugin's docs live. https only. */
docs?: string;
/** Environment variables the plugin reads. */
env?: PluginEnvVar[];
engine: PluginEngine;
/** Catalog capability ids. */
capabilities: string[];
@@ -654,6 +673,8 @@ const ALLOWED_TOP_LEVEL = new Set([
"icon",
"video",
"features",
"docs",
"env",
"dependencies",
"optionalDependencies",
"provides",
@@ -816,6 +837,12 @@ export function validateManifest(manifest: unknown): string[] {
);
}
validateFeatures(m.features, errors);
if ("docs" in m && pluginDocsUrl(m.docs) === null) {
errors.push(
`Field "docs" must be an https URL, got: ${JSON.stringify(m.docs)}`,
);
}
validateEnv(m.env, 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);
@@ -873,6 +900,54 @@ function validateFeatures(features: unknown, errors: string[]): void {
});
}
function validateEnv(env: unknown, errors: string[]): void {
if (env === undefined) return;
if (!Array.isArray(env)) {
errors.push('Field "env" must be an array');
return;
}
const seen = new Set<string>();
env.forEach((entry, i) => {
const where = `env[${i}]`;
if (!isPlainObject(entry)) {
errors.push(`Field "${where}" must be an object`);
return;
}
rejectUnknown(
entry,
["name", "description", "default", "required", "secret"],
where,
errors,
);
if (typeof entry.name !== "string" || !ENV_NAME_PATTERN.test(entry.name)) {
errors.push(
`Field "${where}.name" must be an uppercase name like MY_PLUGIN_URL, got: ${JSON.stringify(entry.name)}`,
);
} else if (seen.has(entry.name)) {
errors.push(`Field "${where}.name" repeats ${entry.name}`);
} else {
seen.add(entry.name);
}
if (
typeof entry.description !== "string" ||
entry.description.trim().length === 0 ||
entry.description.length > MAX_ENV_DESCRIPTION_LENGTH
) {
errors.push(
`Field "${where}.description" must be a non-empty string of at most ${MAX_ENV_DESCRIPTION_LENGTH} characters`,
);
}
if ("default" in entry && typeof entry.default !== "string") {
errors.push(`Field "${where}.default" must be a string`);
}
for (const flag of ["required", "secret"] as const) {
if (flag in entry && typeof entry[flag] !== "boolean") {
errors.push(`Field "${where}.${flag}" must be true or false`);
}
}
});
}
function validateAuthor(author: unknown, errors: string[]): void {
if (author === undefined) return;
if (!isPlainObject(author)) {
+87
View File
@@ -0,0 +1,87 @@
/**
* Builds an OpenAPI spec from the @openapi JSDoc blocks above routes. Core and
* every plugin use this, so the docs site renders them all the same way.
*/
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
export interface OpenApiTag {
name: string;
description?: string;
}
export interface BuildOpenApiOptions {
title: string;
version: string;
description?: string;
/** Source files or globs holding @openapi blocks. */
files: string[];
tags?: OpenApiTag[];
}
export type OpenApiSpec = Record<string, unknown> & {
paths?: Record<string, unknown>;
};
/** How every Termix route is signed in to. */
export const TERMIX_SECURITY_SCHEMES = {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description:
"Session token sent in the Authorization header as Bearer and the token.",
},
cookieAuth: {
type: "apiKey",
in: "cookie",
name: "jwt",
description:
"Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.",
},
apiKeyAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "API key starting with tmx_",
description:
"API key sent in the Authorization header as Bearer and the key, which starts with tmx_. It acts as the key owner and cannot act as anyone else.",
},
} as const;
export async function buildOpenApi(
options: BuildOpenApiOptions,
): Promise<OpenApiSpec> {
const spec = (await swaggerJSDoc({
definition: {
openapi: "3.0.3",
info: {
title: options.title,
version: options.version,
...(options.description ? { description: options.description } : {}),
},
servers: [{ url: "/", description: "Your Termix server" }],
components: {
securitySchemes: TERMIX_SECURITY_SCHEMES,
schemas: {
Error: {
type: "object",
properties: {
error: { type: "string" },
details: { type: "string" },
},
},
},
},
security: [{ bearerAuth: [] }, { cookieAuth: [] }, { apiKeyAuth: [] }],
...(options.tags?.length ? { tags: options.tags } : {}),
},
apis: options.files.map((f) => f.replace(/\\/g, "/")),
failOnErrors: true,
} as Parameters<typeof swaggerJSDoc>[0])) as OpenApiSpec;
return spec;
}
/** Whether a spec documents any route. */
export function hasOpenApiPaths(spec: OpenApiSpec): boolean {
return Object.keys(spec.paths ?? {}).length > 0;
}
+1
View File
@@ -85,6 +85,7 @@ const offline = () =>
Promise.reject(new Error("No plugin API in a unit test; mock it"));
(globalThis as Record<string, unknown>).__termixTestPluginHost = {
usePluginId: () => "test-plugin",
useDocsUrl: () => null,
useTranslation: () => ({
t: (key: string) => key,
language: "en",