mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3
This commit is contained in:
1 parent
7fcda3bb01
commit
cfab6f764c
65 files changed
+1665
-294
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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.*).
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>>
|
||||
|
||||
Reference in new issue
Block a user