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

+14
View File
@@ -1,5 +1,19 @@
# Changelog
## 1.0.3
Plugin API 1.2.0.
### Added
- `docs` manifest field: an https link to the plugin's docs. Termix shows it as a Documentation link on the plugin's page and in its settings
- `env` manifest field: the environment variables the plugin reads, listed in its docs and on its page
- `app.docs` and `useDocsUrl()` for links into the plugin's own docs, and `DocsLink` in `@termix-ssh/plugin-sdk/ui`
- `PanelShell` takes a `docs` link and shows it as a book icon in the header
- `@termix-ssh/plugin-sdk/openapi` with `buildOpenApi()`, and `termix-plugin openapi`, which writes `dist/openapi.json` from the `@openapi` blocks in `src/backend`. `build` runs it too
- `@termix-ssh/plugin-sdk/docs` with `pluginDocsUrl()`, `pluginDocsPage()` and `pluginEnvVars()`
- `termix-plugin validate` warns when `docs/index.md` is missing or the source reads an env var the manifest does not list
## 1.0.2
### Added
+3 -2
View File
@@ -12,7 +12,7 @@
## Overview
`@termix-ssh/plugin-sdk` is what every [Termix](https://github.com/Termix-SSH/Termix) plugin builds against, including the ones that ship with Termix. It has the types for a plugin's backend and frontend, table definitions, the manifest schema, test helpers, and the `termix-plugin` CLI. The easiest way to start is the [Termix Plugin Template](https://github.com/Termix-SSH/Termix-Plugin-Template).
`@termix-ssh/plugin-sdk` is what every [Termix](https://github.com/Termix-SSH/Termix) plugin builds against, including the ones that ship with Termix. It has the types for a plugin's backend and frontend, table definitions, the manifest schema, test helpers, and the `termix-plugin` CLI. The easiest way to start is the [Termix Plugin Template](https://github.com/Termix-SSH/Termix-Plugin-Template). The full guide is at [docs.termix.site/develop](https://docs.termix.site/develop).
<br />
@@ -30,6 +30,7 @@ npm install --save-dev @termix-ssh/plugin-sdk
npx termix-plugin build # bundle dist/backend.js and dist/frontend.js
npx termix-plugin test # run the plugin's Vitest suite
npx termix-plugin validate # check manifest.json and the files it names
npx termix-plugin openapi # write dist/openapi.json from @openapi comments
npx termix-plugin migrations # write migrations from the table definitions
npx termix-plugin pack # write <id>-<version>.tmxplug
npx termix-plugin changelog # print this version's notes from CHANGELOG.md
@@ -39,7 +40,7 @@ npx termix-plugin keygen --out <dir> # make a new signing key pair
npx termix-plugin dev --server <url> --key <tmx_...> # install on a server and reinstall on every change
```
`dev` needs developer mode on (Admin settings, Plugins) and an API key from an
`dev` needs developer mode on (Settings, General) and an API key from an
admin account. It installs the plugin from a file, marked unverified, and
reinstalls it each time you save. `TERMIX_SERVER_URL` and `TERMIX_API_KEY` work
in place of the flags. Reload the page to pick up frontend changes.
@@ -6,6 +6,7 @@ import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
import { staticUrlImports } from "../lib/static-url-imports.mjs";
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
import { openapi } from "./openapi.mjs";
const BACKEND_ENTRIES = [
"src/backend/index.ts",
@@ -241,6 +242,7 @@ export async function build({ cwd }) {
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
await openapi({ cwd, quiet: true });
console.log(`built ${pluginId}`);
}
+1 -1
View File
@@ -42,7 +42,7 @@ export function resolveDevOptions(args, env = process.env) {
const HINTS = {
DEVELOPER_MODE_OFF:
"Turn on developer mode in Admin settings, under Plugins, then save again.",
"Turn on Plugin developer mode in Settings, General, then save again.",
SIGNED_ONLY:
"The server sets TERMIX_REQUIRE_SIGNED_PLUGINS, so it never installs unsigned files.",
BUNDLED_ID: "That id ships with Termix. Use a different id while developing.",
@@ -0,0 +1,39 @@
import fs from "node:fs";
import path from "node:path";
import { readManifest } from "../lib/plugin-dir.mjs";
import { loadSdkModule } from "./validate.mjs";
/**
* Writes dist/openapi.json from the @openapi blocks in src/backend. A plugin
* with no documented routes gets no file.
*/
export async function openapi({ cwd, quiet = false }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
const backendDir = path.join(cwd, "src", "backend");
if (!fs.existsSync(backendDir)) return null;
const { buildOpenApi, hasOpenApiPaths } = await loadSdkModule("openapi");
const spec = await buildOpenApi({
title: `${manifest.name ?? pluginId} API`,
version: manifest.version ?? "0.0.0",
description: manifest.description,
files: [path.join(backendDir, "**", "*.{ts,tsx,js,mjs}")],
});
const target = path.join(cwd, "dist", "openapi.json");
if (!hasOpenApiPaths(spec)) {
fs.rmSync(target, { force: true });
if (!quiet) console.log(`${pluginId}: no documented routes`);
return null;
}
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, `${JSON.stringify(spec, null, 2)}\n`);
if (!quiet) {
console.log(
`wrote dist/openapi.json (${Object.keys(spec.paths).length} paths)`,
);
}
return target;
}
@@ -41,6 +41,10 @@ export async function validate({ cwd }) {
problems.push(...validatePackage(cwd, raw));
problems.push(...(await validateChangelogFile(cwd, raw)));
for (const warning of docsWarnings(cwd, raw)) {
console.warn(` warn ${warning}`);
}
if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`);
throw new Error(`${raw.id ?? path.basename(cwd)}: manifest is not valid.`);
@@ -49,6 +53,69 @@ export async function validate({ cwd }) {
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
}
/** Env vars core sets for everyone, so a plugin need not list them. */
const CORE_ENV = new Set([
"DATA_DIR",
"NODE_ENV",
"PORT",
"BASE_PATH",
"VERSION",
"HOME",
"TMPDIR",
"TEMP",
"TMP",
"PATH",
"APPDATA",
"LOCALAPPDATA",
"USERPROFILE",
"ELECTRON_EMBEDDED",
]);
const ENV_READS = [
/process\.env\.([A-Z][A-Z0-9_]*)/g,
/process\.env\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
/\benv\.([A-Z][A-Z0-9_]*)\b/g,
/\benv\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
];
function walkSources(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkSources(full, out);
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(entry.name)) out.push(full);
}
return out;
}
/**
* Docs gaps that do not block a release: no docs/index.md, or a process.env
* read the manifest does not list in env.
*/
export function docsWarnings(cwd, raw) {
const warnings = [];
if (!fs.existsSync(path.join(cwd, "docs", "index.md"))) {
warnings.push("docs/index.md is missing, so the plugin has no docs page");
}
const declared = new Set(
Array.isArray(raw.env) ? raw.env.map((e) => e?.name) : [],
);
const missing = new Set();
for (const file of walkSources(path.join(cwd, "src"))) {
const text = fs.readFileSync(file, "utf8");
for (const pattern of ENV_READS) {
for (const match of text.matchAll(pattern)) {
const name = match[1];
if (!declared.has(name) && !CORE_ENV.has(name)) missing.add(name);
}
}
}
for (const name of [...missing].sort()) {
warnings.push(`reads ${name} but manifest env does not list it`);
}
return warnings;
}
/**
* package.json has to agree with the manifest: the same version, and a real
* SDK range rather than "*", so a plugin built against a newer SDK says so.
+3
View File
@@ -15,6 +15,7 @@ import { sign, verify, keygen } from "./commands/sign.mjs";
import { migrations } from "./commands/migrations.mjs";
import { dev } from "./commands/dev.mjs";
import { changelog } from "./commands/changelog.mjs";
import { openapi } from "./commands/openapi.mjs";
const COMMANDS = {
build,
@@ -32,6 +33,7 @@ const COMMANDS = {
migrations,
dev,
changelog,
openapi,
};
const [command, ...args] = process.argv.slice(2);
@@ -50,6 +52,7 @@ if (!command || command === "--help" || command === "-h") {
" verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
" keygen Write a new signing key pair [--out dir]",
" migrations Generate migrations from the plugin's table definitions",
" openapi Write dist/openapi.json from the @openapi blocks in src/backend",
" changelog Print a version's notes from CHANGELOG.md [--version x.y.z]",
" dev Build, pack and install on a running server, again on every change",
" [--server url] [--key tmx_...] [--once]",
+10 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.2",
"version": "1.0.3",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -53,6 +53,14 @@
"types": "./dist/changelog.d.ts",
"default": "./dist/changelog.js"
},
"./docs": {
"types": "./dist/docs.d.ts",
"default": "./dist/docs.js"
},
"./openapi": {
"types": "./dist/openapi.d.ts",
"default": "./dist/openapi.js"
},
"./settings": {
"types": "./dist/settings.d.ts",
"default": "./dist/settings.js"
@@ -102,6 +110,7 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
"@tailwindcss/node": "^4.2.4",
"@tailwindcss/oxide": "^4.2.4",
"esbuild": "^0.28.1",
@@ -85,6 +85,27 @@
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
"description": "Short lines listing what the plugin does, shown on its page."
},
"docs": {
"type": "string",
"pattern": "^https://",
"description": "Where the plugin's docs live. Shown as a Documentation link in Termix."
},
"env": {
"type": "array",
"description": "Environment variables the plugin reads, listed in its docs.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "description"],
"properties": {
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
"default": { "type": "string" },
"required": { "type": "boolean" },
"secret": { "type": "boolean" }
}
}
},
"engine": {
"type": "object",
"additionalProperties": false,
+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",
+3 -1
View File
@@ -58,6 +58,8 @@ DialogOverlay (value): ({ className, ...props }: DialogOverlayProps & RefAttribu
DialogPortal (value): ({ ...props }: DialogPortalProps) => Element
DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element
DocsLinkProps (type): DocsLinkProps
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
@@ -128,7 +130,7 @@ PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, o
PanelDensity (type): PanelDensity
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; className?: string; children: ReactNode; }) => Element
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, docs, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; docs?: string; className?: string; children: ReactNode; }) => Element
PanelViewMode (type): PanelViewMode
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>