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,7 +14,10 @@ import {
isTermixCompatible,
youtubeVideoId,
pluginFeatures,
pluginDocsUrl,
pluginEnvVars,
} from "../../plugins/manifest.js";
import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs";
function base(overrides: Record<string, unknown> = {}) {
return {
@@ -929,3 +932,73 @@ describe("youtubeVideoId", () => {
expect(youtubeVideoId(undefined)).toBeNull();
});
});
describe("docs and env fields", () => {
it("accepts an https docs link and well formed env entries", () => {
expect(
validateManifest(
base({
docs: "https://docs.termix.site/plugins/sample-plugin",
env: [
{ name: "SAMPLE_URL", description: "Where to connect." },
{
name: "SAMPLE_TOKEN",
description: "Token.",
secret: true,
required: true,
default: "",
},
],
}),
),
).toEqual([]);
});
it("refuses a docs link that is not https", () => {
const errors = validateManifest(base({ docs: "http://example.com" }));
expect(errors.join(" ")).toMatch(/"docs" must be an https URL/);
});
it("refuses bad env entries", () => {
const errors = validateManifest(
base({
env: [
{ name: "lower_case", description: "x" },
{ name: "DUP", description: "x" },
{ name: "DUP", description: "x" },
{ name: "NO_DESC", description: "" },
{ name: "EXTRA", description: "x", other: 1 },
{ name: "FLAG", description: "x", secret: "yes" },
],
}),
).join(" ");
expect(errors).toMatch(/env\[0\]\.name/);
expect(errors).toMatch(/env\[2\]\.name" repeats DUP/);
expect(errors).toMatch(/env\[3\]\.description/);
expect(errors).toMatch(/Unknown field "other" in env\[4\]/);
expect(errors).toMatch(/env\[5\]\.secret/);
});
it("reads docs links and env entries leniently", () => {
expect(pluginDocsUrl("https://docs.termix.site/plugins/x/")).toBe(
"https://docs.termix.site/plugins/x",
);
expect(pluginDocsUrl("file:///etc/passwd")).toBeNull();
expect(pluginDocsUrl(42)).toBeNull();
expect(
pluginEnvVars([
{ name: "OK_VAR", description: "fine" },
{ name: "bad", description: "x" },
"nope",
]),
).toEqual([{ name: "OK_VAR", description: "fine" }]);
});
it("joins pages and anchors onto a docs link", () => {
const base = "https://docs.termix.site/plugins/docker";
expect(pluginDocsPage(base)).toBe(base);
expect(pluginDocsPage(base, "/setup/")).toBe(`${base}/setup`);
expect(pluginDocsPage(base, "setup", "#ports")).toBe(`${base}/setup#ports`);
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
});
});
@@ -155,6 +155,23 @@ describe("parseRegistryIndex", () => {
expect(index.plugins[1].features).toEqual([]);
});
it("keeps only https docs links", () => {
const index = parseRegistryIndex({
plugins: [
{
id: "docker",
docs: "https://docs.termix.site/plugins/docker/",
versions: [version()],
},
{ id: "other", docs: "javascript:alert(1)", versions: [version()] },
],
});
expect(index.plugins[0].docs).toBe(
"https://docs.termix.site/plugins/docker",
);
expect(index.plugins[1].docs).toBeUndefined();
});
it("refuses something that is not an index", () => {
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
});
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
buildOpenApi,
hasOpenApiPaths,
TERMIX_SECURITY_SCHEMES,
} from "@termix-ssh/plugin-sdk/openapi";
let dir: string | null = null;
afterEach(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
dir = null;
});
function source(files: Record<string, string>): string {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "sdk-openapi-"));
for (const [name, text] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), text);
}
return dir;
}
describe("buildOpenApi", () => {
it("reads @openapi blocks from TypeScript sources", async () => {
const root = source({
"routes.ts": [
"/**",
" * @openapi",
" * /plugin-api/sample/items:",
" * get:",
" * summary: List items",
" * responses:",
" * 200:",
" * description: The items",
" */",
'router.get("/items", handler);',
].join("\n"),
});
const spec = await buildOpenApi({
title: "Sample API",
version: "1.2.3",
files: [path.join(root, "*.ts")],
tags: [{ name: "Sample" }],
});
expect(hasOpenApiPaths(spec)).toBe(true);
expect(Object.keys(spec.paths ?? {})).toEqual(["/plugin-api/sample/items"]);
expect(spec).toMatchObject({
openapi: "3.0.3",
info: { title: "Sample API", version: "1.2.3" },
tags: [{ name: "Sample" }],
components: { securitySchemes: TERMIX_SECURITY_SCHEMES },
});
});
it("returns a spec with no paths when nothing is documented", async () => {
const root = source({ "index.ts": "export const x = 1;" });
const spec = await buildOpenApi({
title: "Empty",
version: "1.0.0",
files: [path.join(root, "*.ts")],
});
expect(hasOpenApiPaths(spec)).toBe(false);
});
});