mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: changelog.md release notes, v tags, plugin release notes and videos
This commit is contained in:
1 parent
b9dd3488a5
commit
9bc2be721b
45 files changed
+1462
-816
No files matched your search
@@ -18,6 +18,7 @@ import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
import { PluginManageError } from "../../plugins/manage.js";
|
||||
import { youtubeVideoId } from "../../plugins/manifest.js";
|
||||
import {
|
||||
isPluginChoice,
|
||||
type PluginChoice,
|
||||
@@ -255,6 +256,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
let description: string | undefined;
|
||||
let author: string | undefined;
|
||||
let repository: string | undefined;
|
||||
let videoId: string | undefined;
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
@@ -263,6 +265,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
description?: unknown;
|
||||
author?: unknown;
|
||||
repository?: unknown;
|
||||
video?: unknown;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
@@ -284,6 +287,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
typeof manifest?.repository === "string"
|
||||
? manifest.repository
|
||||
: undefined;
|
||||
videoId = youtubeVideoId(manifest?.video) ?? undefined;
|
||||
} catch {
|
||||
contributes = null;
|
||||
}
|
||||
@@ -313,6 +317,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
description,
|
||||
author,
|
||||
repository,
|
||||
videoId,
|
||||
signedBy: loaded?.signedBy ?? null,
|
||||
capabilities,
|
||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||
@@ -1984,6 +1989,48 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/changelog:
|
||||
* get:
|
||||
* summary: Read an installed plugin's release notes
|
||||
* description: The releases in the plugin's CHANGELOG.md, newest first, each with its date, summary and changes. Empty when the plugin ships no changelog.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The releases.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
*/
|
||||
router.get(
|
||||
"/:id/changelog",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
try {
|
||||
const { getPluginChangelog } = await import("../../plugins/manage.js");
|
||||
res.json({ id: pluginId, releases: await getPluginChangelog(pluginId) });
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to read the plugin's release notes",
|
||||
"plugin_changelog",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/developer-mode:
|
||||
|
||||
@@ -45,6 +45,12 @@ import { readInstallCounts } from "./install-counts.js";
|
||||
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
||||
import { parseManifest, type PluginManifest } from "./manifest.js";
|
||||
import { requireSignedPlugins } from "./trust.js";
|
||||
import {
|
||||
parseChangelog,
|
||||
parseReleaseNotes,
|
||||
type ChangelogRelease,
|
||||
type ReleaseNotes,
|
||||
} from "@termix-ssh/plugin-sdk/changelog";
|
||||
|
||||
const UNINSTALLED_KEY = "plugins_uninstalled";
|
||||
const DEVELOPER_MODE_KEY = "plugins_developer_mode";
|
||||
@@ -206,12 +212,14 @@ export interface RegistryListing {
|
||||
category: string;
|
||||
repository?: string;
|
||||
icon?: string;
|
||||
videoId?: string;
|
||||
versions: Array<{
|
||||
version: string;
|
||||
compatible: boolean;
|
||||
capabilities: string[];
|
||||
publishedAt?: string;
|
||||
releaseNotesUrl?: string;
|
||||
notes?: ReleaseNotes;
|
||||
size: number;
|
||||
}>;
|
||||
latestVersion: string | null;
|
||||
@@ -263,12 +271,14 @@ export async function listRegistry(
|
||||
category: plugin.category,
|
||||
repository: plugin.repository,
|
||||
icon: plugin.icon,
|
||||
videoId: plugin.videoId,
|
||||
versions: plugin.versions.map((entry) => ({
|
||||
version: entry.version,
|
||||
compatible: isApiCompatible(entry.api),
|
||||
capabilities: entry.capabilities,
|
||||
publishedAt: entry.publishedAt,
|
||||
releaseNotesUrl: entry.releaseNotesUrl,
|
||||
notes: readReleaseNotes(entry.notes),
|
||||
size: entry.size,
|
||||
})),
|
||||
latestVersion: latest?.version ?? null,
|
||||
@@ -963,6 +973,40 @@ export function setPluginOptions(
|
||||
});
|
||||
}
|
||||
|
||||
const MAX_CHANGELOG_BYTES = 512 * 1024;
|
||||
|
||||
/** A registry version's Markdown notes, parsed. Undefined when empty. */
|
||||
export function readReleaseNotes(
|
||||
markdown: string | undefined,
|
||||
): ReleaseNotes | undefined {
|
||||
if (!markdown) return undefined;
|
||||
const { summary, changes } = parseReleaseNotes(markdown);
|
||||
if (!summary && changes.length === 0) return undefined;
|
||||
return { ...(summary ? { summary } : {}), changes };
|
||||
}
|
||||
|
||||
/**
|
||||
* The releases in an installed plugin's CHANGELOG.md, newest first. Empty when
|
||||
* the plugin ships none or it cannot be read.
|
||||
*/
|
||||
export async function getPluginChangelog(
|
||||
pluginId: string,
|
||||
): Promise<ChangelogRelease[]> {
|
||||
const plugin = getPluginRuntime().loader.get(pluginId);
|
||||
if (!plugin) {
|
||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||
}
|
||||
const file = path.join(plugin.dir, "CHANGELOG.md");
|
||||
try {
|
||||
const stat = await fs.promises.stat(file);
|
||||
if (!stat.isFile() || stat.size > MAX_CHANGELOG_BYTES) return [];
|
||||
return parseChangelog(await fs.promises.readFile(file, "utf8")).changelog
|
||||
.releases;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function directorySize(dir: string): Promise<number> {
|
||||
let total = 0;
|
||||
let entries: fs.Dirent[];
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
SYSTEM_ROLE_NAMES,
|
||||
RESERVED_PERMISSION_PREFIXES,
|
||||
qualifyPermission,
|
||||
youtubeVideoId,
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
export type {
|
||||
|
||||
@@ -12,7 +12,10 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import { SUPPORTED_PLUGIN_API_VERSION } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import {
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
youtubeVideoId,
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { verifyPluginArtifact } from "./trust.js";
|
||||
@@ -27,6 +30,7 @@ const CACHE_MS = 15 * 60_000;
|
||||
const MAX_INDEX_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
|
||||
const MAX_REDIRECTS = 5;
|
||||
const MAX_NOTES_LENGTH = 20_000;
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
export interface RegistryVersion {
|
||||
@@ -38,6 +42,8 @@ export interface RegistryVersion {
|
||||
size: number;
|
||||
capabilities: string[];
|
||||
releaseNotesUrl?: string;
|
||||
/** This version's CHANGELOG.md section, as Markdown. */
|
||||
notes?: string;
|
||||
publishedAt?: string;
|
||||
}
|
||||
|
||||
@@ -49,6 +55,8 @@ export interface RegistryPlugin {
|
||||
category: string;
|
||||
repository?: string;
|
||||
icon?: string;
|
||||
/** Only ever a YouTube video id, never a link. */
|
||||
videoId?: string;
|
||||
/** Newest first. */
|
||||
versions: RegistryVersion[];
|
||||
}
|
||||
@@ -95,6 +103,7 @@ function parseVersion(raw: unknown): RegistryVersion | null {
|
||||
? entry.capabilities.filter((c): c is string => typeof c === "string")
|
||||
: [],
|
||||
releaseNotesUrl: asString(entry.releaseNotesUrl),
|
||||
notes: asString(entry.notes)?.slice(0, MAX_NOTES_LENGTH),
|
||||
publishedAt: asString(entry.publishedAt),
|
||||
};
|
||||
}
|
||||
@@ -128,6 +137,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
|
||||
category: asString(entry.category) ?? "",
|
||||
repository: asString(entry.repository),
|
||||
icon: asString(entry.icon),
|
||||
videoId: youtubeVideoId(entry.video) ?? undefined,
|
||||
versions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ const manageMock = vi.hoisted(() => ({
|
||||
uninstallPlugin: vi.fn(),
|
||||
setPluginOptions: vi.fn(),
|
||||
getPluginDataSummary: vi.fn(),
|
||||
getPluginChangelog: vi.fn(),
|
||||
deletePluginData: vi.fn(),
|
||||
planStateChange: vi.fn(),
|
||||
setPluginState: vi.fn(),
|
||||
@@ -832,6 +833,21 @@ describe("plugins route", () => {
|
||||
expect(manageMock.setPluginState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an installed plugin's release notes", async () => {
|
||||
manageMock.getPluginChangelog.mockResolvedValue([
|
||||
{ version: "1.0.0", changes: [{ type: "added", text: "First" }] },
|
||||
]);
|
||||
const res = await fetch(`${baseUrl}/plugins/docker/changelog`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
id: "docker",
|
||||
releases: [
|
||||
{ version: "1.0.0", changes: [{ type: "added", text: "First" }] },
|
||||
],
|
||||
});
|
||||
expect(manageMock.getPluginChangelog).toHaveBeenCalledWith("docker");
|
||||
});
|
||||
|
||||
it("answers 502 when the registry is unreachable", async () => {
|
||||
manageMock.listRegistry.mockRejectedValue(new Error("offline"));
|
||||
const res = await fetch(`${baseUrl}/plugins/registry`);
|
||||
@@ -856,6 +872,7 @@ describe("plugins route", () => {
|
||||
body: JSON.stringify({ autoUpdate: true }),
|
||||
}),
|
||||
fetch(`${baseUrl}/plugins/docker/data`, { headers }),
|
||||
fetch(`${baseUrl}/plugins/docker/changelog`, { headers }),
|
||||
fetch(`${baseUrl}/plugins/developer-mode`, { headers }),
|
||||
fetch(`${baseUrl}/plugins/developer-mode`, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -680,3 +680,40 @@ describe("installing from a file", () => {
|
||||
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("release notes", () => {
|
||||
it("reads an installed plugin's CHANGELOG.md", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
fs.writeFileSync(
|
||||
path.join(bundled, "docker", "CHANGELOG.md"),
|
||||
"# Changelog\n\n## 1.1.0 - 2026-10-06\n\n### Fixed\n- A bug\n\n## 1.0.0\n\n### Added\n- First\n",
|
||||
);
|
||||
await boot();
|
||||
expect(await manage.getPluginChangelog("docker")).toEqual([
|
||||
{
|
||||
version: "1.1.0",
|
||||
date: "2026-10-06",
|
||||
changes: [{ type: "fixed", text: "A bug" }],
|
||||
},
|
||||
{ version: "1.0.0", changes: [{ type: "added", text: "First" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is empty without a changelog and 404s for an unknown plugin", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
await boot();
|
||||
expect(await manage.getPluginChangelog("docker")).toEqual([]);
|
||||
await expect(manage.getPluginChangelog("nope")).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses registry notes and drops empty ones", () => {
|
||||
expect(manage.readReleaseNotes("Summary.\n\n### Added\n- Thing")).toEqual({
|
||||
summary: "Summary.",
|
||||
changes: [{ type: "added", text: "Thing" }],
|
||||
});
|
||||
expect(manage.readReleaseNotes("")).toBeUndefined();
|
||||
expect(manage.readReleaseNotes(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
validateManifest,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
isTermixCompatible,
|
||||
youtubeVideoId,
|
||||
} from "../../plugins/manifest.js";
|
||||
|
||||
function base(overrides: Record<string, unknown> = {}) {
|
||||
@@ -852,3 +853,47 @@ describe("contributes.protocols", () => {
|
||||
expect(errors.join("\n")).toMatch(/extra/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("manifest video", () => {
|
||||
it("accepts YouTube links", () => {
|
||||
for (const video of [
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://youtu.be/dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"https://youtube.com/shorts/dQw4w9WgXcQ",
|
||||
]) {
|
||||
expect(validateManifest(base({ video }))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects anything that is not a YouTube video", () => {
|
||||
for (const video of [
|
||||
"https://evil.example/watch?v=dQw4w9WgXcQ",
|
||||
"http://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://www.youtube.com.evil.example/watch?v=dQw4w9WgXcQ",
|
||||
"https://user@www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/watch?v=short",
|
||||
"https://www.youtube.com/channel/UC123",
|
||||
"javascript:alert(1)",
|
||||
"",
|
||||
42,
|
||||
]) {
|
||||
expect(validateManifest(base({ video }))).toEqual([
|
||||
expect.stringContaining('Field "video" must be a YouTube link'),
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("youtubeVideoId", () => {
|
||||
it("returns only the id", () => {
|
||||
expect(
|
||||
youtubeVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42"),
|
||||
).toBe("dQw4w9WgXcQ");
|
||||
expect(youtubeVideoId("https://youtu.be/dQw4w9WgXcQ?si=abc")).toBe(
|
||||
"dQw4w9WgXcQ",
|
||||
);
|
||||
expect(youtubeVideoId("https://youtu.be/../../x")).toBeNull();
|
||||
expect(youtubeVideoId(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,28 @@ describe("parseRegistryIndex", () => {
|
||||
expect(index.plugins.map((p) => p.id)).toEqual(["ok"]);
|
||||
});
|
||||
|
||||
it("keeps release notes and only a YouTube video id", () => {
|
||||
const NOTES = "### Added\n- Thing";
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
video: "https://youtu.be/dQw4w9WgXcQ",
|
||||
versions: [version({ notes: NOTES })],
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
video: "https://evil.example/watch?v=dQw4w9WgXcQ",
|
||||
versions: [version({ notes: "x".repeat(30_000) })],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(index.plugins[0].videoId).toBe("dQw4w9WgXcQ");
|
||||
expect(index.plugins[0].versions[0].notes).toBe(NOTES);
|
||||
expect(index.plugins[1].videoId).toBeUndefined();
|
||||
expect(index.plugins[1].versions[0].notes).toHaveLength(20_000);
|
||||
});
|
||||
|
||||
it("refuses something that is not an index", () => {
|
||||
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { rbacApi } from "@/main-axios";
|
||||
import type { ChoiceAdjustment, PluginChoice } from "@/types/plugin-onboarding";
|
||||
import type {
|
||||
ChangelogRelease,
|
||||
ReleaseNotes,
|
||||
} from "@termix-ssh/plugin-sdk/changelog";
|
||||
|
||||
export type { PluginChoice };
|
||||
|
||||
@@ -246,6 +250,8 @@ export interface PluginSummary {
|
||||
description?: string;
|
||||
author?: string;
|
||||
repository?: string;
|
||||
/** A YouTube video id from the manifest. Admins only. */
|
||||
videoId?: string;
|
||||
signedBy?: string | null;
|
||||
}
|
||||
|
||||
@@ -267,6 +273,7 @@ export interface RegistryPluginVersion {
|
||||
capabilities: string[];
|
||||
publishedAt?: string;
|
||||
releaseNotesUrl?: string;
|
||||
notes?: ReleaseNotes;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@@ -278,6 +285,7 @@ export interface RegistryPluginEntry {
|
||||
category: string;
|
||||
repository?: string;
|
||||
icon?: string;
|
||||
videoId?: string;
|
||||
versions: RegistryPluginVersion[];
|
||||
latestVersion: string | null;
|
||||
installed: boolean;
|
||||
@@ -448,6 +456,13 @@ export async function getPluginData(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getPluginChangelog(
|
||||
pluginId: string,
|
||||
): Promise<ChangelogRelease[]> {
|
||||
const response = await rbacApi.get(`${pluginPath(pluginId)}/changelog`);
|
||||
return response.data?.releases ?? [];
|
||||
}
|
||||
|
||||
export async function deletePluginData(pluginId: string): Promise<void> {
|
||||
try {
|
||||
await rbacApi.delete(`${pluginPath(pluginId)}/data`);
|
||||
|
||||
+13
-1
@@ -2105,8 +2105,20 @@
|
||||
"switchVersion": "Switch",
|
||||
"latestVersion": "{{version}} (latest)",
|
||||
"versionChange": "{{from}} to {{to}}",
|
||||
"versionHistory": "Version history",
|
||||
"releaseNotes": "Release notes",
|
||||
"noReleaseNotes": "No notes for this version.",
|
||||
"viewRelease": "View on GitHub",
|
||||
"showAllReleases": "Show all {{count}}",
|
||||
"showFewerReleases": "Show fewer",
|
||||
"videoTitle": "{{name}} video",
|
||||
"changeTypes": {
|
||||
"added": "Added",
|
||||
"changed": "Changed",
|
||||
"fixed": "Fixed",
|
||||
"removed": "Removed",
|
||||
"deprecated": "Deprecated",
|
||||
"security": "Security"
|
||||
},
|
||||
"installedBadge": "Installed",
|
||||
"incompatible": "Needs a newer Termix",
|
||||
"whatItCanDo": "What it can do",
|
||||
|
||||
@@ -37,17 +37,12 @@ import {
|
||||
type PluginEntry,
|
||||
} from "./plugin-model";
|
||||
import { openPluginSettings } from "./open-plugins";
|
||||
import { PluginReleaseNotes, PluginVideo } from "./PluginReleaseNotes";
|
||||
import type { PluginsManager } from "./use-plugins-manager";
|
||||
|
||||
const LINK_ROW =
|
||||
"flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground";
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function PluginDetail({
|
||||
plugin,
|
||||
manager,
|
||||
@@ -206,6 +201,8 @@ export function PluginDetail({
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-2 lg:col-span-2">
|
||||
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
|
||||
|
||||
<SectionCard title={t("plugins.manager.whatItCanDo")} icon={null}>
|
||||
<div className="divide-y divide-border">
|
||||
{orderByRisk(plugin.capabilities).map((capability) => (
|
||||
@@ -224,48 +221,7 @@ export function PluginDetail({
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{plugin.versions.length > 0 && (
|
||||
<SectionCard
|
||||
title={t("plugins.manager.versionHistory")}
|
||||
icon={null}
|
||||
>
|
||||
<div className="divide-y divide-border">
|
||||
{plugin.versions.map((v) => (
|
||||
<div
|
||||
key={v.version}
|
||||
className="flex flex-col gap-0.5 px-4 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold">{v.version}</span>
|
||||
{v.version === plugin.version && (
|
||||
<span className="border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-accent-brand">
|
||||
{t("plugins.manager.installedBadge")}
|
||||
</span>
|
||||
)}
|
||||
{!v.compatible && (
|
||||
<span className="border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">
|
||||
{t("plugins.manager.incompatible")}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||
{formatDate(v.publishedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{v.releaseNotesUrl && (
|
||||
<a
|
||||
href={v.releaseNotesUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-fit text-[11px] text-muted-foreground hover:text-accent-brand"
|
||||
>
|
||||
{t("plugins.manager.releaseNotes")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
<PluginReleaseNotes plugin={plugin} />
|
||||
|
||||
{plugin.installed && (
|
||||
<DataCard
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { getPluginChangelog } from "@/api/plugins-api";
|
||||
import type { ChangelogRelease } from "@termix-ssh/plugin-sdk/changelog";
|
||||
import {
|
||||
groupChanges,
|
||||
mergeReleaseNotes,
|
||||
youtubeEmbedUrl,
|
||||
type PluginEntry,
|
||||
type ReleaseRow,
|
||||
} from "./plugin-model";
|
||||
|
||||
const SHOWN = 3;
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
|
||||
}
|
||||
|
||||
/** Backticks become code, everything else stays plain text. */
|
||||
function InlineText({ text }: { text: string }) {
|
||||
return (
|
||||
<>
|
||||
{text.split(/(`[^`]+`)/).map((part, i) =>
|
||||
part.length > 2 && part.startsWith("`") && part.endsWith("`") ? (
|
||||
<code
|
||||
key={i}
|
||||
className="bg-muted px-1 py-px font-mono text-[10px] text-foreground"
|
||||
>
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Release({
|
||||
row,
|
||||
installedVersion,
|
||||
}: {
|
||||
row: ReleaseRow;
|
||||
installedVersion: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const groups = groupChanges(row.changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold">{row.version}</span>
|
||||
{row.version === installedVersion && (
|
||||
<span className="border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-accent-brand">
|
||||
{t("plugins.manager.installedBadge")}
|
||||
</span>
|
||||
)}
|
||||
{!row.compatible && (
|
||||
<span className="border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">
|
||||
{t("plugins.manager.incompatible")}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||
{formatDate(row.date)}
|
||||
</span>
|
||||
</div>
|
||||
{row.summary && (
|
||||
<p className="whitespace-pre-line text-[11px] leading-relaxed text-muted-foreground">
|
||||
<InlineText text={row.summary} />
|
||||
</p>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<div key={group.type} className="flex flex-col gap-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||
{t(`plugins.manager.changeTypes.${group.type}`)}
|
||||
</span>
|
||||
<ul className="flex list-disc flex-col gap-0.5 pl-4 text-[11px] leading-snug">
|
||||
{group.items.map((item, i) => (
|
||||
<li key={i} className="whitespace-pre-line">
|
||||
<InlineText text={item} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
{groups.length === 0 && !row.summary && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.noReleaseNotes")}
|
||||
</span>
|
||||
)}
|
||||
{row.releaseNotesUrl && (
|
||||
<a
|
||||
href={row.releaseNotesUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-fit text-[11px] text-muted-foreground hover:text-accent-brand"
|
||||
>
|
||||
{t("plugins.manager.viewRelease")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every version's notes, from the registry and the installed CHANGELOG.md.
|
||||
* Renders nothing when there are no versions to list.
|
||||
*/
|
||||
export function PluginReleaseNotes({ plugin }: { plugin: PluginEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const [local, setLocal] = useState<ChangelogRelease[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLocal([]);
|
||||
if (!plugin.installed) return;
|
||||
getPluginChangelog(plugin.id)
|
||||
.then((releases) => {
|
||||
if (!cancelled) setLocal(releases);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [plugin.id, plugin.installed, plugin.version]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => mergeReleaseNotes(plugin.versions, local),
|
||||
[plugin.versions, local],
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
const shown = expanded ? rows : rows.slice(0, SHOWN);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("plugins.manager.releaseNotes")}
|
||||
icon={null}
|
||||
action={
|
||||
rows.length > SHOWN ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded
|
||||
? t("plugins.manager.showFewerReleases")
|
||||
: t("plugins.manager.showAllReleases", { count: rows.length })}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="-mx-3 -my-1 divide-y divide-border md:-mx-4">
|
||||
{shown.map((row) => (
|
||||
<Release
|
||||
key={row.version}
|
||||
row={row}
|
||||
installedVersion={plugin.version}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The plugin's YouTube video. Nothing is loaded when it has none. */
|
||||
export function PluginVideo({
|
||||
videoId,
|
||||
name,
|
||||
}: {
|
||||
videoId?: string;
|
||||
name: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const src = youtubeEmbedUrl(videoId);
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<div className="aspect-video w-full border border-border bg-black">
|
||||
<iframe
|
||||
src={src}
|
||||
title={t("plugins.manager.videoTitle", { name })}
|
||||
className="size-full"
|
||||
loading="lazy"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
sandbox="allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox"
|
||||
allow="encrypted-media; picture-in-picture; fullscreen"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import {
|
||||
getCapabilityInfo,
|
||||
type CapabilityRisk,
|
||||
} from "@termix-ssh/plugin-sdk/capabilities";
|
||||
import semver from "semver";
|
||||
import {
|
||||
CHANGE_TYPES,
|
||||
type ChangeType,
|
||||
type ChangelogChange,
|
||||
type ChangelogRelease,
|
||||
} from "@termix-ssh/plugin-sdk/changelog";
|
||||
import type {
|
||||
PluginSummary,
|
||||
PluginUploadPreview,
|
||||
@@ -21,6 +28,7 @@ export interface PluginEntry {
|
||||
category: string;
|
||||
icon?: string;
|
||||
repository?: string;
|
||||
videoId?: string;
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
latestVersion: string | null;
|
||||
@@ -80,6 +88,7 @@ export function mergePlugins(
|
||||
category: entry?.category ?? "",
|
||||
icon: summary.icon ?? entry?.icon,
|
||||
repository: summary.repository ?? entry?.repository,
|
||||
videoId: summary.videoId ?? entry?.videoId,
|
||||
installed: true,
|
||||
version: summary.version,
|
||||
latestVersion: entry?.latestVersion ?? null,
|
||||
@@ -115,6 +124,7 @@ export function mergePlugins(
|
||||
category: entry.category,
|
||||
icon: entry.icon,
|
||||
repository: entry.repository,
|
||||
videoId: entry.videoId,
|
||||
installed: false,
|
||||
version: null,
|
||||
latestVersion: entry.latestVersion,
|
||||
@@ -338,3 +348,73 @@ export function reportIssueUrl(
|
||||
});
|
||||
return `${repo}/issues/new?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** One version in a plugin's release notes. */
|
||||
export interface ReleaseRow {
|
||||
version: string;
|
||||
date: string | null;
|
||||
summary?: string;
|
||||
changes: ChangelogChange[];
|
||||
/** False only for a registry version this build cannot run. */
|
||||
compatible: boolean;
|
||||
releaseNotesUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry's versions and the installed copy's CHANGELOG.md as one list,
|
||||
* newest first. The installed copy's notes win for a version in both.
|
||||
*/
|
||||
export function mergeReleaseNotes(
|
||||
versions: RegistryPluginVersion[],
|
||||
local: ChangelogRelease[],
|
||||
): ReleaseRow[] {
|
||||
const rows = new Map<string, ReleaseRow>();
|
||||
for (const v of versions) {
|
||||
rows.set(v.version, {
|
||||
version: v.version,
|
||||
date: v.publishedAt ?? null,
|
||||
summary: v.notes?.summary,
|
||||
changes: v.notes?.changes ?? [],
|
||||
compatible: v.compatible,
|
||||
releaseNotesUrl: v.releaseNotesUrl,
|
||||
});
|
||||
}
|
||||
for (const release of local) {
|
||||
const row = rows.get(release.version);
|
||||
rows.set(release.version, {
|
||||
version: release.version,
|
||||
date: release.date ?? row?.date ?? null,
|
||||
summary: release.summary ?? row?.summary,
|
||||
changes:
|
||||
release.changes.length > 0 ? release.changes : (row?.changes ?? []),
|
||||
compatible: row?.compatible ?? true,
|
||||
releaseNotesUrl: row?.releaseNotesUrl,
|
||||
});
|
||||
}
|
||||
return [...rows.values()].sort((a, b) =>
|
||||
semver.valid(a.version) && semver.valid(b.version)
|
||||
? semver.rcompare(a.version, b.version)
|
||||
: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/** The changes of one release grouped by type, in the changelog's order. */
|
||||
export function groupChanges(
|
||||
changes: ChangelogChange[],
|
||||
): Array<{ type: ChangeType; items: string[] }> {
|
||||
const groups: Array<{ type: ChangeType; items: string[] }> = [];
|
||||
for (const type of CHANGE_TYPES) {
|
||||
const items = changes.filter((c) => c.type === type).map((c) => c.text);
|
||||
if (items.length > 0) groups.push({ type, items });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
|
||||
|
||||
/** The embed address for a video id, or null for anything that is not one. */
|
||||
export function youtubeEmbedUrl(videoId: string | undefined): string | null {
|
||||
return videoId && YOUTUBE_ID.test(videoId)
|
||||
? `https://www.youtube-nocookie.com/embed/${videoId}?rel=0`
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
import type { PluginEntry } from "@/plugins/plugin-model";
|
||||
|
||||
const getPluginChangelog = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/plugins-api", () => ({ getPluginChangelog }));
|
||||
|
||||
const { PluginReleaseNotes, PluginVideo } =
|
||||
await import("@/plugins/PluginReleaseNotes");
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
getPluginChangelog.mockReset();
|
||||
});
|
||||
|
||||
async function i18n() {
|
||||
const instance = i18next.createInstance();
|
||||
await instance.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
plugins: {
|
||||
manager: {
|
||||
releaseNotes: "Release notes",
|
||||
installedBadge: "Installed",
|
||||
changeTypes: { added: "Added", fixed: "Fixed" },
|
||||
videoTitle: "{{name}} video",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
function plugin(overrides: Partial<PluginEntry> = {}): PluginEntry {
|
||||
return {
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
installed: true,
|
||||
version: "1.0.0",
|
||||
versions: [],
|
||||
...overrides,
|
||||
} as PluginEntry;
|
||||
}
|
||||
|
||||
describe("PluginReleaseNotes", () => {
|
||||
it("shows the installed copy's changelog grouped by type", async () => {
|
||||
getPluginChangelog.mockResolvedValue([
|
||||
{
|
||||
version: "1.0.0",
|
||||
changes: [
|
||||
{ type: "added", text: "Runs `docker ps`" },
|
||||
{ type: "fixed", text: "A crash" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
render(
|
||||
<I18nextProvider i18n={await i18n()}>
|
||||
<PluginReleaseNotes plugin={plugin()} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("A crash")).toBeTruthy());
|
||||
expect(getPluginChangelog).toHaveBeenCalledWith("docker");
|
||||
expect(screen.getByText("Added")).toBeTruthy();
|
||||
expect(screen.getByText("docker ps").tagName).toBe("CODE");
|
||||
expect(screen.getByText("Installed")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders nothing for a plugin with no versions", async () => {
|
||||
const { container } = render(
|
||||
<I18nextProvider i18n={await i18n()}>
|
||||
<PluginReleaseNotes plugin={plugin({ installed: false })} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
expect(getPluginChangelog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginVideo", () => {
|
||||
it("embeds a YouTube id", async () => {
|
||||
render(
|
||||
<I18nextProvider i18n={await i18n()}>
|
||||
<PluginVideo videoId="dQw4w9WgXcQ" name="Docker" />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
const frame = screen.getByTitle("Docker video") as HTMLIFrameElement;
|
||||
expect(frame.src).toBe(
|
||||
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads nothing without a valid id", async () => {
|
||||
const { container } = render(
|
||||
<I18nextProvider i18n={await i18n()}>
|
||||
<PluginVideo videoId="https://evil.example" name="Docker" />
|
||||
<PluginVideo name="Docker" />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
expect(container.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
describeContributions,
|
||||
formatBytes,
|
||||
formatCount,
|
||||
groupChanges,
|
||||
matchesQuery,
|
||||
mergeReleaseNotes,
|
||||
mergePlugins,
|
||||
orderByRisk,
|
||||
pluginSource,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
reportIssueUrl,
|
||||
sortPlugins,
|
||||
uploadEntry,
|
||||
youtubeEmbedUrl,
|
||||
} from "@/plugins/plugin-model";
|
||||
|
||||
function summary(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
@@ -283,3 +286,88 @@ describe("reportIssueUrl", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeReleaseNotes", () => {
|
||||
const registryVersion = (version: string, extra = {}) => ({
|
||||
version,
|
||||
compatible: true,
|
||||
capabilities: [],
|
||||
size: 1,
|
||||
publishedAt: "2026-10-01T00:00:00Z",
|
||||
...extra,
|
||||
});
|
||||
|
||||
it("joins registry and local notes newest first, local winning", () => {
|
||||
const rows = mergeReleaseNotes(
|
||||
[
|
||||
registryVersion("1.2.0", {
|
||||
compatible: false,
|
||||
notes: { changes: [{ type: "added", text: "Registry" }] },
|
||||
}),
|
||||
registryVersion("1.0.0", {
|
||||
releaseNotesUrl: "https://github.com/x/y/releases/tag/v1.0.0",
|
||||
notes: { changes: [{ type: "added", text: "Old" }] },
|
||||
}),
|
||||
],
|
||||
[
|
||||
{
|
||||
version: "1.0.0",
|
||||
date: "2026-09-01",
|
||||
summary: "First.",
|
||||
changes: [{ type: "added", text: "Local" }],
|
||||
},
|
||||
{ version: "0.9.0", changes: [{ type: "fixed", text: "Beta" }] },
|
||||
],
|
||||
);
|
||||
expect(rows.map((r) => r.version)).toEqual(["1.2.0", "1.0.0", "0.9.0"]);
|
||||
expect(rows[0]).toMatchObject({ compatible: false });
|
||||
expect(rows[1]).toEqual({
|
||||
version: "1.0.0",
|
||||
date: "2026-09-01",
|
||||
summary: "First.",
|
||||
changes: [{ type: "added", text: "Local" }],
|
||||
compatible: true,
|
||||
releaseNotesUrl: "https://github.com/x/y/releases/tag/v1.0.0",
|
||||
});
|
||||
expect(rows[2]).toMatchObject({ date: null, compatible: true });
|
||||
});
|
||||
|
||||
it("is empty with nothing to show", () => {
|
||||
expect(mergeReleaseNotes([], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupChanges", () => {
|
||||
it("groups by type in a fixed order", () => {
|
||||
expect(
|
||||
groupChanges([
|
||||
{ type: "fixed", text: "b" },
|
||||
{ type: "added", text: "a" },
|
||||
{ type: "fixed", text: "c" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "added", items: ["a"] },
|
||||
{ type: "fixed", items: ["b", "c"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("youtubeEmbedUrl", () => {
|
||||
it("builds a privacy embed from a video id only", () => {
|
||||
expect(youtubeEmbedUrl("dQw4w9WgXcQ")).toBe(
|
||||
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses anything that is not an id", () => {
|
||||
for (const value of [
|
||||
undefined,
|
||||
"",
|
||||
"https://evil.example/x",
|
||||
"dQw4w9WgXcQ/../../x",
|
||||
"javascript:alert(1)",
|
||||
]) {
|
||||
expect(youtubeEmbedUrl(value)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user