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

+15 -1
View File
@@ -19,7 +19,13 @@ import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
import { PluginManageError } from "../../plugins/manage.js";
import { parsePluginChannel } from "../../plugins/registry-index.js";
import { pluginFeatures, youtubeVideoId } from "../../plugins/manifest.js";
import {
pluginDocsUrl,
pluginEnvVars,
pluginFeatures,
youtubeVideoId,
type PluginEnvVar,
} from "../../plugins/manifest.js";
import {
isPluginChoice,
type PluginChoice,
@@ -259,6 +265,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let repository: string | undefined;
let videoId: string | undefined;
let features: string[] = [];
let docs: string | undefined;
let env: PluginEnvVar[] = [];
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
@@ -269,6 +277,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
repository?: unknown;
video?: unknown;
features?: unknown;
docs?: unknown;
env?: unknown;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
@@ -292,6 +302,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
: undefined;
videoId = youtubeVideoId(manifest?.video) ?? undefined;
features = pluginFeatures(manifest?.features);
docs = pluginDocsUrl(manifest?.docs) ?? undefined;
env = pluginEnvVars(manifest?.env);
} catch {
contributes = null;
}
@@ -306,6 +318,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
icon,
dependencies,
optionalDependencies,
docs,
...describePluginFrontend(loaded),
};
@@ -324,6 +337,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
repository,
videoId,
features,
env,
signedBy: loaded?.signedBy ?? null,
capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
+2
View File
@@ -222,6 +222,7 @@ export interface RegistryListing {
icon?: string;
videoId?: string;
features: string[];
docs?: string;
versions: Array<{
version: string;
prerelease: boolean;
@@ -290,6 +291,7 @@ export async function listRegistry(
icon: plugin.icon,
videoId: plugin.videoId,
features: plugin.features,
docs: plugin.docs,
versions: channelReleases(plugin, "beta").map((entry) => ({
version: entry.version,
prerelease: Boolean(entry.prerelease),
+3
View File
@@ -19,10 +19,13 @@ export {
qualifyPermission,
youtubeVideoId,
pluginFeatures,
pluginDocsUrl,
pluginEnvVars,
} from "@termix-ssh/plugin-sdk/manifest";
export type {
PluginManifest,
PluginEnvVar,
PluginAuthor,
PluginEngine,
PluginContributions,
+4
View File
@@ -13,6 +13,7 @@ import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import {
pluginDocsUrl,
pluginFeatures,
SUPPORTED_PLUGIN_API_VERSION,
youtubeVideoId,
@@ -67,6 +68,8 @@ export interface RegistryPlugin {
/** Only ever a YouTube video id, never a link. */
videoId?: string;
features: string[];
/** https docs link from the index. */
docs?: string;
/** Stable releases, newest first. */
versions: RegistryVersion[];
/**
@@ -161,6 +164,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
icon: asString(entry.icon),
videoId: youtubeVideoId(entry.video) ?? undefined,
features: pluginFeatures(entry.features),
docs: pluginDocsUrl(entry.docs) ?? undefined,
versions,
prereleases,
});
@@ -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);
});
});
+32
View File
@@ -0,0 +1,32 @@
[
{ "name": "Auth", "description": "Sign in, sign out and login methods" },
{ "name": "Users", "description": "Accounts, profiles and sessions" },
{ "name": "API Keys", "description": "API keys for scripts and the CLI" },
{ "name": "Encryption", "description": "Data key setup and unlock" },
{ "name": "SSH", "description": "Hosts, folders and connections" },
{
"name": "SSH Auth",
"description": "SSH auth types the server can connect with"
},
{
"name": "Host Defaults",
"description": "Admin, user and folder host defaults"
},
{ "name": "Host Enrollment", "description": "Adding hosts from a script" },
{ "name": "Credentials", "description": "Saved credentials" },
{ "name": "RBAC", "description": "Roles and sharing" },
{ "name": "Dashboard", "description": "Dashboard stats and recent activity" },
{ "name": "Audit", "description": "Audit log search and export" },
{ "name": "Plugins", "description": "Installing and managing plugins" },
{ "name": "Sync", "description": "Desktop sync with a linked server" },
{ "name": "Database", "description": "Database export, import and health" },
{ "name": "General", "description": "Server info and settings" },
{ "name": "Open Tabs", "description": "Saved open tabs" },
{ "name": "User Preferences", "description": "Per-user preferences" },
{ "name": "UI Preferences", "description": "Interface layout preferences" },
{ "name": "Host Sidebar", "description": "Host sidebar display options" },
{
"name": "Credential Sidebar",
"description": "Credential sidebar display options"
}
]
-176
View File
@@ -1,176 +0,0 @@
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
import path from "path";
import { fileURLToPath } from "url";
import { promises as fs } from "fs";
import { systemLogger } from "./logger.js";
type SwaggerJSDocOptions = Parameters<typeof swaggerJSDoc>[0];
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const swaggerOptions: SwaggerJSDocOptions = {
definition: {
openapi: "3.0.3",
info: {
title: "Termix API",
version: "0.0.0",
description: "Termix Backend API Reference",
},
servers: [
{
url: "http://localhost:30001",
description: "Main database and authentication server",
},
],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description: "Session JWT sent as Authorization: Bearer <JWT>.",
},
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: "tmx_<api key>",
description:
"API key sent as Authorization: Bearer tmx_<api key>. Permissions follow the key owner's account; encrypted data must be unlocked separately. API keys cannot impersonate another user.",
},
},
schemas: {
Error: {
type: "object",
properties: {
error: { type: "string" },
details: { type: "string" },
},
},
},
},
security: [
{
bearerAuth: [],
},
{
cookieAuth: [],
},
{
apiKeyAuth: [],
},
],
tags: [
{
name: "Credentials",
description: "SSH credential management",
},
{
name: "RBAC",
description: "Role-based access control for host sharing",
},
{
name: "Users",
description: "User management and authentication",
},
{
name: "Dashboard",
description: "Dashboard statistics and activity",
},
{
name: "SSH",
description: "SSH host management and configuration",
},
{
name: "Host Enrollment",
description: "Host enrollment and onboarding",
},
{
name: "Open Tabs",
description: "Per-user open tab state",
},
{
name: "Audit",
description: "Audit log querying and export",
},
{
name: "API Keys",
description: "API key management",
},
{
name: "Sync",
description: "Remote sync between desktop and server",
},
{
name: "User Preferences",
description: "Per-user application preferences",
},
{
name: "UI Preferences",
description: "Interface layout and display preferences",
},
{
name: "Host Sidebar",
description: "Host sidebar display preferences",
},
{
name: "Credential Sidebar",
description: "Credential sidebar display preferences",
},
],
},
apis: [
path.join(__dirname, "..", "database", "database.js").replace(/\\/g, "/"),
path
.join(__dirname, "..", "database", "routes", "*.js")
.replace(/\\/g, "/"),
path.join(__dirname, "..", "services", "*.js").replace(/\\/g, "/"),
path.join(__dirname, "..", "hosts", "*.js").replace(/\\/g, "/"),
path.join(__dirname, "..", "hosts", "**", "*.js").replace(/\\/g, "/"),
],
};
async function generateOpenAPISpec() {
try {
systemLogger.info("Generating OpenAPI specification", {
operation: "openapi_generate_start",
});
const swaggerSpec = await swaggerJSDoc(swaggerOptions);
const outputPath = path.join(
__dirname,
"..",
"..",
"..",
"..",
"openapi.json",
);
await fs.writeFile(
outputPath,
JSON.stringify(swaggerSpec, null, 2),
"utf-8",
);
systemLogger.success("OpenAPI specification generated", {
operation: "openapi_generate_success",
});
} catch (error) {
systemLogger.error("Failed to generate OpenAPI specification", error, {
operation: "openapi_generation",
});
process.exit(1);
}
}
generateOpenAPISpec();
export { swaggerOptions, generateOpenAPISpec };
+6
View File
@@ -4,6 +4,7 @@ import type {
ChangelogRelease,
ReleaseNotes,
} from "@termix-ssh/plugin-sdk/changelog";
import type { PluginEnvVar } from "@termix-ssh/plugin-sdk/docs";
export type { PluginChoice };
@@ -255,6 +256,10 @@ export interface PluginSummary {
videoId?: string;
/** What the plugin does, from the manifest. Admins only. */
features?: string[];
/** https docs link from the manifest. */
docs?: string;
/** Environment variables the plugin reads. Admins only. */
env?: PluginEnvVar[];
signedBy?: string | null;
}
@@ -294,6 +299,7 @@ export interface RegistryPluginEntry {
icon?: string;
videoId?: string;
features?: string[];
docs?: string;
versions: RegistryPluginVersion[];
/** Newest release on the plugin's channel. */
latestVersion: string | null;
+99
View File
@@ -0,0 +1,99 @@
/* eslint-disable react-refresh/only-export-components */
import { BookOpen } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs";
import { docsUrl, type DocsPage } from "@/lib/docs";
import { cn } from "@/lib/utils";
import { usePluginScope } from "@/plugin-host/scope";
import { usePluginStore } from "@/plugin-host/plugin-store";
/** A plugin's docs link from its manifest, or null when it has none. */
export function usePluginDocsUrl(
pluginId: string | null | undefined,
page?: string,
anchor?: string,
): string | null {
const { records } = usePluginStore();
const base = pluginId ? records.get(pluginId)?.summary.docs : undefined;
return base ? pluginDocsPage(base, page, anchor) : null;
}
export interface DocsLinkProps {
/** A core docs page. */
core?: DocsPage;
/**
* A page of the current plugin's docs, like "setup". Inside a plugin this
* resolves against its manifest docs link; "" is its main page.
*/
page?: string;
anchor?: string;
/** A full URL, used as is. */
href?: string | null;
/** "text" is a small inline link, "icon" a square header button. */
variant?: "text" | "icon";
children?: ReactNode;
className?: string;
}
/**
* The one way the app links to docs. Renders nothing when there is nowhere
* to go, like a community plugin with no docs link.
*/
export function DocsLink({
core,
page,
anchor,
href,
variant = "text",
children,
className,
}: DocsLinkProps) {
const { t } = useTranslation();
const pluginId = usePluginScope();
const pluginUrl = usePluginDocsUrl(
page !== undefined ? pluginId : null,
page,
anchor,
);
const url =
href ?? (core ? withAnchor(docsUrl(core), anchor) : null) ?? pluginUrl;
if (!url) return null;
const label = t("common.docs");
if (variant === "icon") {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
title={label}
aria-label={label}
className={cn(
"flex size-7 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground",
className,
)}
>
<BookOpen className="size-3.5" />
</a>
);
}
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"shrink-0 text-[10px] text-accent-brand hover:underline",
className,
)}
>
{children ?? label}
</a>
);
}
function withAnchor(url: string, anchor?: string): string {
if (!anchor) return url;
return `${url.split("#")[0]}#${anchor.replace(/^#/, "")}`;
}
+9 -2
View File
@@ -5,6 +5,7 @@ import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link";
/** One spacing rhythm for every panel and tab. */
export const PANEL = {
@@ -30,6 +31,7 @@ export function PanelShell({
tabs,
footer,
scroll = true,
docs,
className,
children,
}: {
@@ -49,6 +51,8 @@ export function PanelShell({
footer?: React.ReactNode;
/** False when the body scrolls itself. */
scroll?: boolean;
/** Docs URL for this screen, shown as a book icon in the header. */
docs?: string | null;
className?: string;
children: React.ReactNode;
}) {
@@ -120,8 +124,11 @@ export function PanelShell({
</>
)}
</div>
{actions && (
<div className="flex shrink-0 items-center gap-1 px-2">{actions}</div>
{(actions || docs) && (
<div className="flex shrink-0 items-center gap-1 px-2">
{actions}
{docs && <DocsLink href={docs} variant="icon" />}
</div>
)}
</header>
{bands}
+3 -1
View File
@@ -62,6 +62,7 @@ import {
type VersionStatus,
} from "./dashboard-core-cards";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { docsUrl } from "@/lib/docs";
export { HostStatusList as HostStatusCard } from "./dashboard-core-cards";
@@ -803,7 +804,7 @@ export function DashboardTab({
href: "https://discord.com/invite/jVQGdvHDrf",
label: t("dashboard.discord"),
},
{ href: "https://docs.termix.site/", label: t("dashboard.docs") },
{ href: docsUrl(), label: t("dashboard.docs") },
{ href: "https://donate.termix.site/", label: t("dashboard.donate") },
];
@@ -827,6 +828,7 @@ export function DashboardTab({
title={title}
status={isDashboardView ? todayLabel : undefined}
scroll={false}
docs={docsUrl("dashboard")}
actions={
<>
{isDashboardView && !viewPending && (
+41
View File
@@ -0,0 +1,41 @@
{
"home": "",
"install": "install",
"updating": "install/updating",
"betas": "configure/betas",
"envVars": "configure/environment-variables",
"https": "configure/https",
"database": "configure/database",
"reverseProxy": "configure/reverse-proxy",
"desktopSync": "configure/desktop-sync",
"interface": "guide/interface",
"dashboard": "guide/dashboard",
"manage": "guide/manage",
"hosts": "guide/hosts",
"portKnocking": "guide/hosts#port-knocking",
"hostDefaults": "guide/host-defaults",
"credentials": "guide/credentials",
"sharing": "guide/sharing",
"account": "guide/account",
"accountSecurity": "guide/account#security",
"accountData": "guide/account#your-data",
"apiKeys": "guide/api-keys",
"commandPalette": "guide/command-palette",
"settings": "guide/settings",
"interfaceSettings": "guide/settings#interface",
"appearance": "guide/settings#appearance",
"keybindings": "guide/settings#keybindings",
"users": "guide/users",
"sessions": "guide/users#sessions",
"roles": "guide/roles",
"adminGeneral": "guide/admin-settings",
"branding": "guide/admin-settings#branding",
"adminDatabase": "guide/admin-settings#database",
"adminSsl": "configure/https",
"auditLog": "guide/audit-log",
"importExport": "guide/import-export",
"plugins": "guide/plugins",
"pluginCatalog": "plugins",
"cli": "cli",
"develop": "develop"
}
+17
View File
@@ -0,0 +1,17 @@
import DOCS_PAGES from "./docs-pages.json";
export const DOCS_URL = "https://docs.termix.site";
/**
* Every core docs page the app links to. The docs site checks each one
* exists, so add new links here rather than writing URLs inline.
*/
export type DocsPage = keyof typeof DOCS_PAGES;
/** Full URL of a core docs page. */
export function docsUrl(page: DocsPage = "home"): string {
const path = DOCS_PAGES[page] ?? "";
return path ? `${DOCS_URL}/${path}` : `${DOCS_URL}/`;
}
export { DOCS_PAGES };
+11 -4
View File
@@ -208,7 +208,8 @@
"mobileSidebarDescription": "Displays the mobile sidebar.",
"toggleSidebar": "Toggle Sidebar",
"search": "Search...",
"new": "New"
"new": "New",
"docs": "Docs"
},
"onboarding": {
"stepCounter": "Step {{current}} of {{total}}",
@@ -252,7 +253,8 @@
"desktopLocalBody": "Everything is stored here. Nothing to set up.",
"desktopLinkTitle": "With my Termix server",
"desktopLinkBody": "Sign in to your server to use the same hosts and settings on every device.",
"desktopLinked": "Linked to {{server}}"
"desktopLinked": "Linked to {{server}}",
"pluginsDocsLink": "Read about each one"
},
"nav": {
"home": "Home",
@@ -930,7 +932,8 @@
"select": "Select",
"toggleWith": "Toggle with",
"pluginActions": "More actions",
"noTargetTab": "No terminal tab is open"
"noTargetTab": "No terminal tab is open",
"documentation": "Documentation"
},
"splitScreen": {
"title": "Split screen",
@@ -2185,7 +2188,11 @@
"waitingForStable": "Back on stable. Stays on {{version}} until a stable release is newer.",
"versionLabel": "{{version}} (beta)",
"sendFeedback": "Send beta feedback"
}
},
"documentation": "Documentation",
"envVars": "Environment variables",
"envRequired": "Required",
"envDefault": "Default: {{value}}"
}
},
"settings": {
+8
View File
@@ -43,6 +43,7 @@ import {
type ManageMode,
type ManageRequest,
} from "./manage-requests";
import { docsUrl } from "@/lib/docs";
type Editing =
| {
@@ -440,6 +441,13 @@ export function ManageTab() {
title={t("nav.manage")}
status={status}
scroll={false}
docs={docsUrl(
mode === "credentials"
? "credentials"
: mode === "hosts"
? "hosts"
: "hostDefaults",
)}
leading={
editing ? (
<span className="flex h-full md:hidden">
+5 -1
View File
@@ -19,6 +19,7 @@ import {
defaultChoices,
recommendedChoices,
} from "../plugin-choices";
import { DocsLink } from "@/components/docs-link";
const CATEGORY_ORDER = [
"Terminal",
@@ -173,7 +174,10 @@ export function PluginsStep({
return (
<div className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">
{t("onboarding.pluginsIntro")}
{t("onboarding.pluginsIntro")}{" "}
<DocsLink core="pluginCatalog" className="text-xs">
{t("onboarding.pluginsDocsLink")}
</DocsLink>
</p>
<div className="flex items-start gap-2 border border-border bg-muted/30 p-2.5 text-[11px] leading-snug text-muted-foreground">
+17
View File
@@ -72,6 +72,7 @@ import {
} from "./host-scope";
import { manifestDeclares, type ViewKind } from "./view-ownership";
import { pluginKey } from "@/lib/plugin-i18n";
import { pluginDocsPage, pluginDocsUrl } from "@termix-ssh/plugin-sdk/docs";
import { hasPermission } from "@/hooks/use-permissions";
import i18n from "@/i18n/i18n";
@@ -83,6 +84,20 @@ export interface PluginAppHandle {
dispose: () => void;
}
function createDocsApi(manifest: PluginManifest): TermixApp["docs"] {
const url = pluginDocsUrl(manifest?.docs);
const page = (name?: string, anchor?: string) =>
url ? pluginDocsPage(url, name, anchor) : null;
return {
url,
page,
open(name, anchor) {
const target = page(name, anchor);
if (target) window.open(target, "_blank", "noopener,noreferrer");
},
};
}
/**
* Builds the object a plugin's `activate(app)` receives.
*
@@ -602,6 +617,8 @@ export function createPluginApp(
confirm: (options) => shell.confirm!(options),
docs: createDocsApi(manifest),
desktop: {
available: isElectron(),
remoteServerUrl,
+5
View File
@@ -39,6 +39,7 @@ import {
} from "@/api/open-tabs-api";
import { runKeybindingAction } from "@/shell/keybinding-registry";
import { usePluginScope } from "./scope";
import { usePluginDocsUrl } from "@/components/docs-link";
import { knownPluginIds, usePluginStore } from "./plugin-store";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import type { UiPluginPresets } from "@/types/ui-preferences";
@@ -225,6 +226,10 @@ export const pluginHostBridge: PluginHostBridge = {
return pluginId;
},
useDocsUrl(pluginId, page, anchor) {
return usePluginDocsUrl(pluginId, page, anchor);
},
useTranslation(pluginId) {
const { t, i18n } = useI18nTranslation(pluginId);
return {
+2
View File
@@ -289,6 +289,8 @@ export {
type PanelDensity,
type PanelViewMode,
} from "@/components/panel-layout";
// A link to docs. Inside a plugin, page="setup" opens that page of its docs.
export { DocsLink, type DocsLinkProps } from "@/components/docs-link";
export {
AddButton,
FormFooter,
+49
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
BookOpen,
Bug,
Check,
Database,
@@ -262,6 +263,40 @@ export function PluginDetail({
</div>
</SectionCard>
{plugin.env.length > 0 && (
<SectionCard title={t("plugins.manager.envVars")} icon={null}>
<div className="divide-y divide-border">
{plugin.env.map((v) => (
<div
key={v.name}
className="flex flex-col gap-0.5 px-4 py-2.5"
>
<div className="flex flex-wrap items-center gap-2">
<code className="font-mono text-xs font-semibold">
{v.name}
</code>
{v.required && (
<span className="text-[9px] font-bold uppercase tracking-widest text-accent-brand">
{t("plugins.manager.envRequired")}
</span>
)}
{v.default !== undefined && (
<span className="font-mono text-[10px] text-muted-foreground">
{t("plugins.manager.envDefault", {
value: v.default,
})}
</span>
)}
</div>
<span className="text-[11px] leading-snug text-muted-foreground">
{v.description}
</span>
</div>
))}
</div>
</SectionCard>
)}
<PluginReleaseNotes plugin={plugin} />
{plugin.installed && (
@@ -419,6 +454,20 @@ export function PluginDetail({
</SectionCard>
)}
{plugin.docs && (
<a
href={plugin.docs}
target="_blank"
rel="noreferrer"
className={LINK_ROW}
>
<BookOpen className="size-3.5 shrink-0" />
<span className="truncate">
{t("plugins.manager.documentation")}
</span>
</a>
)}
{hasSettings && (
<button
type="button"
+13 -1
View File
@@ -53,6 +53,8 @@ import {
type SortKey,
} from "./plugin-model";
import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
import { docsUrl } from "@/lib/docs";
import { DocsLink } from "@/components/docs-link";
type Section = "installed" | "browse" | "updates";
@@ -102,6 +104,7 @@ function PluginsTabBody() {
<PanelShell
icon={<Puzzle className="size-4" />}
title={t("nav.plugins")}
docs={docsUrl("plugins")}
status={t("plugins.manager.status.summary", {
installed: installed.length,
running,
@@ -550,13 +553,21 @@ function InstalledCard({
: t("plugins.manager.enable")}
</Button>
)}
<DocsLink
href={plugin.docs}
variant="icon"
className="ml-auto size-6"
/>
<Button
variant="ghost"
size="icon-xs"
disabled={busy || locked}
title={t("plugins.manager.uninstall")}
aria-label={t("plugins.manager.uninstall")}
className="ml-auto text-muted-foreground hover:text-destructive"
className={cn(
"text-muted-foreground hover:text-destructive",
!plugin.docs && "ml-auto",
)}
onClick={() => void manager.uninstall(plugin)}
>
<Trash2 className="size-3" />
@@ -690,6 +701,7 @@ function BrowseCard({
})
: t("plugins.manager.noServerAccess")}
</span>
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
{plugin.installed ? (
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-accent-brand">
<Check className="size-3" />
+9
View File
@@ -3,6 +3,7 @@ import {
getCapabilityInfo,
type CapabilityRisk,
} from "@termix-ssh/plugin-sdk/capabilities";
import type { PluginEnvVar } from "@termix-ssh/plugin-sdk/docs";
import semver from "semver";
import {
CHANGE_TYPES,
@@ -36,6 +37,9 @@ export interface PluginEntry {
repository?: string;
videoId?: string;
features: string[];
/** Docs link: the installed manifest's, else the registry's. */
docs?: string;
env: PluginEnvVar[];
installed: boolean;
version: string | null;
latestVersion: string | null;
@@ -124,6 +128,8 @@ export function mergePlugins(
features: summary.features?.length
? summary.features
: (entry?.features ?? []),
docs: summary.docs ?? entry?.docs,
env: summary.env ?? [],
installed: true,
version: summary.version,
latestVersion: entry?.latestVersion ?? null,
@@ -164,6 +170,8 @@ export function mergePlugins(
repository: entry.repository,
videoId: entry.videoId,
features: entry.features ?? [],
docs: entry.docs,
env: [],
installed: false,
version: null,
latestVersion: entry.latestVersion,
@@ -201,6 +209,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
description: preview.description,
author: preview.author,
features: preview.features ?? [],
env: [],
category: "",
installed: preview.replaces !== null,
version: preview.replaces,
+2
View File
@@ -13,6 +13,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card";
import { DocsLink } from "@/components/docs-link";
import { PluginIcon } from "@/lib/plugin-icon";
import { type PluginSummary } from "@/api/plugins-api";
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
@@ -142,6 +143,7 @@ function HostPluginSection({
<SectionCard
title={plugin.name}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
action={<DocsLink href={plugin.docs} />}
>
{!running && (
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
+10
View File
@@ -40,6 +40,8 @@ import {
type SettingsBand,
type SettingsPage,
} from "./settings-pages";
import { usePluginDocsUrl } from "@/components/docs-link";
import { docsUrl } from "@/lib/docs";
export interface SettingsTabProps {
/** The page to open, from the tab's data. */
@@ -195,6 +197,13 @@ export function SettingsTab({
window.setTimeout(attempt, 60);
};
const pluginDocs = usePluginDocsUrl(page.pluginId);
const pageDocs = page.pluginId
? pluginDocs
: page.docs
? docsUrl(page.docs)
: null;
const pluginFeature = page.pluginId
? {
user: userFeatures.find((p) => p.id === page.pluginId),
@@ -306,6 +315,7 @@ export function SettingsTab({
title={t("nav.settings")}
status={pageLabel(page)}
scroll={false}
docs={pageDocs}
actions={
<button
type="button"
+2 -3
View File
@@ -21,10 +21,9 @@ import { reportBetaFeedbackUrl } from "@/lib/issue-url";
import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { docsUrl } from "@/lib/docs";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
const BETA_DOCS_URL =
"https://docs.termix.site/install/server/docker#beta-builds";
function onBeta(plugin: PluginSummary): boolean {
return plugin.channel === "beta" || isPrerelease(plugin.version);
@@ -196,7 +195,7 @@ export function UpdatesSettings() {
</>
)}
<a
href={BETA_DOCS_URL}
href={docsUrl("betas")}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent-brand hover:underline"
+21
View File
@@ -17,6 +17,7 @@ import {
type LucideIcon,
} from "lucide-react";
import type { AdminSection } from "@/types/ui-types";
import type { DocsPage } from "@/lib/docs";
import { SETTINGS_SEARCH_INDEX } from "./settings-search-index";
export type SettingsBand = "you" | "app" | "plugins" | "instance";
@@ -45,6 +46,8 @@ export interface SettingsPage {
adminSection?: AdminSection;
/** A plugin's settings page. */
pluginId?: string;
/** The docs page for this settings page. Plugin pages use the plugin's docs. */
docs?: DocsPage;
/** i18n keys whose text search matches. */
searchKeys: string[];
}
@@ -61,6 +64,7 @@ function core(
export const CORE_SETTINGS_PAGES: SettingsPage[] = [
core({
id: "account",
docs: "account",
band: "you",
labelKey: "settings.account",
icon: User,
@@ -68,6 +72,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "security",
docs: "accountSecurity",
band: "you",
labelKey: "settings.security",
icon: Shield,
@@ -76,6 +81,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "api-keys",
docs: "apiKeys",
band: "you",
labelKey: "settings.apiKeys",
icon: KeyRound,
@@ -83,6 +89,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "data",
docs: "accountData",
band: "you",
labelKey: "settings.data",
icon: Database,
@@ -90,6 +97,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "interface",
docs: "interfaceSettings",
band: "app",
labelKey: "settings.interface",
icon: SlidersHorizontal,
@@ -97,6 +105,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "appearance",
docs: "appearance",
band: "app",
labelKey: "settings.appearance",
icon: Palette,
@@ -104,6 +113,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "keybindings",
docs: "keybindings",
band: "app",
labelKey: "settings.keybindings",
icon: Keyboard,
@@ -111,6 +121,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "admin-general",
docs: "adminGeneral",
band: "instance",
labelKey: "settings.adminGeneral",
icon: Settings,
@@ -119,6 +130,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "updates",
docs: "updating",
band: "instance",
labelKey: "settings.updates.title",
icon: FlaskConical,
@@ -126,6 +138,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "admin-users",
docs: "users",
band: "instance",
labelKey: "settings.adminUsers",
icon: Users,
@@ -134,6 +147,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "admin-sessions",
docs: "sessions",
band: "instance",
labelKey: "settings.adminSessions",
icon: Monitor,
@@ -142,6 +156,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "admin-roles",
docs: "roles",
band: "instance",
labelKey: "settings.adminRoles",
icon: Lock,
@@ -150,6 +165,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "admin-host-defaults",
docs: "hostDefaults",
band: "instance",
labelKey: "settings.adminHostDefaults",
icon: Server,
@@ -158,6 +174,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "admin-branding",
docs: "branding",
band: "instance",
labelKey: "settings.adminBranding",
icon: Palette,
@@ -166,6 +183,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "admin-database",
docs: "adminDatabase",
band: "instance",
labelKey: "settings.adminDatabase",
icon: Database,
@@ -174,6 +192,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "admin-ssl",
docs: "adminSsl",
band: "instance",
labelKey: "settings.adminSsl",
icon: Globe,
@@ -182,6 +201,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "admin-api-keys",
docs: "apiKeys",
band: "instance",
labelKey: "settings.adminApiKeys",
icon: KeyRound,
@@ -190,6 +210,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
}),
core({
id: "admin-audit-log",
docs: "auditLog",
band: "instance",
labelKey: "settings.adminAuditLog",
icon: FileText,
+7 -2
View File
@@ -29,6 +29,7 @@ import {
getSavedServerUrls,
rememberServerUrl,
} from "./saved-servers";
import { DocsLink } from "@/components/docs-link";
type Step = "server" | "signin" | "choose" | "finishing";
@@ -454,7 +455,9 @@ export function LinkServerDialog({
scroll={!wide}
>
{!wide && (
<p className="text-xs text-muted-foreground">{description}</p>
<p className="text-xs text-muted-foreground">
{description} {step === "server" && <DocsLink core="desktopSync" />}
</p>
)}
{content}
</InlineView>
@@ -474,7 +477,9 @@ export function LinkServerDialog({
>
<DialogHeader className={wide ? "sr-only" : undefined}>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
<DialogDescription>
{description} {step === "server" && <DocsLink core="desktopSync" />}
</DialogDescription>
</DialogHeader>
{content}
+8
View File
@@ -11,6 +11,7 @@ import {
CommandEmpty,
} from "@/components/command";
import {
BookOpen,
Bug,
Clock,
FlaskConical,
@@ -48,6 +49,7 @@ import { reportBetaFeedbackUrl, reportCoreIssueUrl } from "@/lib/issue-url";
import { isPrerelease } from "@/plugins/plugin-model";
import { shell } from "@/plugin-host/shell-bridge";
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
import { docsUrl } from "@/lib/docs";
interface CommandPaletteProps {
/** Opens settings, optionally at one section. */
@@ -218,6 +220,12 @@ export function CommandPalette({
);
const links = [
{
id: "link-docs",
label: t("commandPalette.documentation"),
icon: BookOpen,
url: docsUrl(),
},
{
id: "link-github",
label: "GitHub",
+3 -16
View File
@@ -10,6 +10,7 @@ import { toast } from "sonner";
import { AccordionSection } from "./AdminSettingsShared";
import type { AdminUser } from "./AdminManagementSections";
import { Select2 } from "@/components/select2";
import { DocsLink } from "@/components/docs-link";
type AdminApiKeysSectionProps = {
open: boolean;
@@ -66,24 +67,10 @@ export function AdminApiKeysSection({
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.apiKeysCount", { count: apiKeys.length })}{" "}
<a
href="https://docs.termix.site/features/api/api-keys"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.apiKeysDocsLink")}
</a>
<DocsLink core="apiKeys">{t("admin.apiKeysDocsLink")}</DocsLink>
<br />
{t("admin.apiKeysCliHint")}{" "}
<a
href="https://docs.termix.site/cli"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.apiKeysCliLink")}
</a>
<DocsLink core="cli">{t("admin.apiKeysCliLink")}</DocsLink>
</span>
<div className="flex items-center gap-1">
<Button
+2 -8
View File
@@ -8,6 +8,7 @@ import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import { AdminHostTags } from "./AdminHostTags";
import { AdminPluginDeveloperMode } from "./AdminPluginDeveloperMode";
import type { TlsStatus } from "@/api/tls-api";
import { DocsLink } from "@/components/docs-link";
type GeneralSettingsSectionProps = {
open: boolean;
@@ -423,14 +424,7 @@ export function AdminSSLSection({
<div className="flex flex-col gap-3 pt-3">
<span className="text-[10px] text-muted-foreground">
{t("admin.sslDescription")}{" "}
<a
href="https://docs.termix.site/features/networking/ssl"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.sslDocsLink")}
</a>
<DocsLink core="https">{t("admin.sslDocsLink")}</DocsLink>
</span>
<div className="flex flex-col gap-0.5 p-2 border border-border bg-background/50">
+2
View File
@@ -55,6 +55,7 @@ import type { AdminSession, AdminUser } from "./AdminManagementSections";
import { TabStrip } from "./HostManagerTabs";
import { BackButton, PanelShell } from "@/components/panel-layout";
import { useActionSlot } from "@/hooks/use-action-slot";
import { docsUrl } from "@/lib/docs";
type ApiErrorLike = {
response?: {
@@ -338,6 +339,7 @@ export function AdminUserManagePanel({
<PanelShell
leading={<BackButton onClick={close} />}
title={t("admin.manageEditorBack", { username: user.username })}
docs={docsUrl("users")}
status={
editor.kind === "host"
? (editor.host?.name ?? t("manage.newHost"))
+2 -8
View File
@@ -32,6 +32,7 @@ import {
import { getUserList, type AccessRecord } from "@/main-axios";
import type { Credential } from "@/types/ui-types";
import { getErrorMessage } from "@/lib/error-message";
import { DocsLink } from "@/components/docs-link";
const PERMISSION_LEVELS: CredentialPermissionLevel[] = ["use", "manage"];
@@ -268,14 +269,7 @@ export function CredentialShareModal({
<Users className="size-3.5" />
{t("hosts.sharing.shareWithSection")}
</div>
<a
href="https://docs.termix.site/features/authentication/rbac"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline shrink-0"
>
{t("hosts.docsLink")}
</a>
<DocsLink core="sharing">{t("hosts.docsLink")}</DocsLink>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
+2 -10
View File
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import {
ArrowUpDown,
Check,
ExternalLink,
Filter,
GripVertical,
Plus,
@@ -29,6 +28,7 @@ import {
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
import { DocsLink } from "@/components/docs-link";
export function CredentialsPanel({
active: _active = true,
@@ -269,15 +269,7 @@ export function CredentialsPanel({
</DropdownMenuContent>
</DropdownMenu>
<div className="w-px self-stretch bg-border" />
<a
href="https://docs.termix.site/features/files-and-hosts/credentials"
target="_blank"
rel="noreferrer"
title={t("hosts.docsLink")}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground shrink-0 transition-colors"
>
<ExternalLink className="size-3.5" />
</a>
<DocsLink core="credentials" variant="icon" />
</div>
<div className="flex items-center border border-border shrink-0">
<button
+2 -8
View File
@@ -22,6 +22,7 @@ import {
HostDefaultBadge,
HostOnly,
} from "@/lib/host-defaults-context";
import { DocsLink } from "@/components/docs-link";
type HostEditorSetField = <K extends keyof HostEditorForm>(
key: K,
@@ -402,14 +403,7 @@ export function HostEditorGeneralTab({
{t("hosts.portKnockingSequence")}
</span>
<HostDefaultBadge settingKey="core.portKnockSequence" />
<a
href="https://docs.termix.site/features/networking/port-knocking"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
<DocsLink core="portKnocking">{t("hosts.docsLink")}</DocsLink>
</div>
<Button
variant="outline"
+2 -8
View File
@@ -39,6 +39,7 @@ import {
} from "@/main-axios";
import type { Host } from "@/types/ui-types";
import { Select2 } from "@/components/select2";
import { DocsLink } from "@/components/docs-link";
const PERMISSION_LEVELS: SharePermissionLevel[] = [
"connect",
@@ -307,14 +308,7 @@ export function HostShareModal({
<Users className="size-3.5" />
{t("hosts.sharing.shareWithSection")}
</div>
<a
href="https://docs.termix.site/features/authentication/rbac"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline shrink-0"
>
{t("hosts.docsLink")}
</a>
<DocsLink core="sharing">{t("hosts.docsLink")}</DocsLink>
</div>
<div className="flex gap-1.5">
+2 -1
View File
@@ -76,6 +76,7 @@ import {
useFeatureSettings,
type FeatureSectionId,
} from "@/settings/FeatureSettingsSections";
import { docsUrl } from "@/lib/docs";
type UserProfileSection =
| "account"
@@ -1345,7 +1346,7 @@ export function UserProfilePanel({
</span>
</div>
<a
href="https://docs.termix.site/cli"
href={docsUrl("cli")}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { DOCS_PAGES, DOCS_URL, docsUrl } from "@/lib/docs";
describe("docsUrl", () => {
it("builds full links to core docs pages", () => {
expect(docsUrl()).toBe(`${DOCS_URL}/`);
expect(docsUrl("hosts")).toBe(`${DOCS_URL}/guide/hosts`);
expect(docsUrl("portKnocking")).toBe(
`${DOCS_URL}/guide/hosts#port-knocking`,
);
});
it("keeps every page a relative path", () => {
for (const path of Object.values(DOCS_PAGES)) {
expect(path).not.toMatch(/^\/|^https?:/);
}
});
});
+17
View File
@@ -108,6 +108,23 @@ describe("mergePlugins", () => {
expect(mergePlugins([summary()], null)[0].features).toEqual([]);
});
it("uses the installed docs link and falls back to the registry", () => {
const registry = entry({ docs: "https://docs.example.com/registry" });
expect(
mergePlugins(
[summary({ docs: "https://docs.example.com/installed" })],
[registry],
)[0].docs,
).toBe("https://docs.example.com/installed");
expect(mergePlugins([summary()], [registry])[0].docs).toBe(
"https://docs.example.com/registry",
);
expect(mergePlugins([summary()], null)[0].docs).toBeUndefined();
expect(mergePlugins([], [registry])[0].docs).toBe(
"https://docs.example.com/registry",
);
});
it("keeps installed plugins when the registry is unreachable", () => {
const rows = mergePlugins([summary()], null);
expect(rows).toHaveLength(1);