feat: changelog.md release notes, v tags, plugin release notes and videos

This commit is contained in:
LukeGus committed 2026-10-06 18:13:39 -05:00
1 parent b9dd3488a5
commit 9bc2be721b
45 files changed
+1462 -816

No files matched your search

+1
View File
@@ -32,6 +32,7 @@ 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 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
npx termix-plugin sign <file.tmxplug> # sign it, needs TERMIX_PLUGIN_SIGNING_KEY
npx termix-plugin verify <file.tmxplug> --key <base64> # check a signature
npx termix-plugin keygen --out <dir> # make a new signing key pair
@@ -0,0 +1,25 @@
import fs from "node:fs";
import path from "node:path";
import { readManifest } from "../lib/plugin-dir.mjs";
import { loadSdkModule } from "./validate.mjs";
/**
* Prints one version's notes from CHANGELOG.md, the manifest version unless
* --version says otherwise. The release workflow uses it as the release body.
*/
export async function changelog({ cwd, args = [] }) {
const index = args.indexOf("--version");
const version =
index === -1 ? readManifest(cwd).version : args[index + 1] || null;
if (!version) throw new Error("--version needs a value");
const file = path.join(cwd, "CHANGELOG.md");
if (!fs.existsSync(file)) throw new Error("There is no CHANGELOG.md");
const { changelogSection } = await loadSdkModule("changelog");
const section = changelogSection(fs.readFileSync(file, "utf8"), version);
if (!section) {
throw new Error(`CHANGELOG.md has no notes for ${version}`);
}
process.stdout.write(`${section}\n`);
}
+16 -24
View File
@@ -2,24 +2,22 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readManifest } from "../lib/plugin-dir.mjs";
import { validateChangelog } from "../lib/changelog.mjs";
/**
* The manifest rules live in src/manifest.ts, which the server uses too, so
* there is one implementation rather than a copy here that drifts.
*/
async function loadParseManifest() {
const entry = new URL("../../dist/manifest.js", import.meta.url);
export async function loadSdkModule(name) {
const entry = new URL(`../../dist/${name}.js`, import.meta.url);
if (!fs.existsSync(fileURLToPath(entry))) {
throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk");
}
const mod = await import(entry.href);
return mod.parseManifest;
return import(entry.href);
}
export async function validate({ cwd }) {
const raw = readManifest(cwd);
const parseManifest = await loadParseManifest();
const { parseManifest } = await loadSdkModule("manifest");
const { errors } = parseManifest(raw);
const problems = [...errors];
@@ -41,7 +39,7 @@ export async function validate({ cwd }) {
);
problems.push(...validateNativeDependencies(cwd, raw));
problems.push(...validatePackage(cwd, raw));
problems.push(...validateChangelogFile(cwd, raw));
problems.push(...(await validateChangelogFile(cwd, raw)));
if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`);
@@ -82,26 +80,20 @@ function validatePackage(cwd, raw) {
}
/**
* CHANGELOG.json is optional, but when it is there it has to parse and its
* CHANGELOG.md is optional, but when it is there it has to parse and its
* newest release has to match the manifest version.
*/
function validateChangelogFile(cwd, raw) {
const jsonPath = path.join(cwd, "CHANGELOG.json");
if (!fs.existsSync(jsonPath)) {
return fs.existsSync(path.join(cwd, "CHANGELOG.md"))
? [
"CHANGELOG.md is no longer shipped; move the release notes to CHANGELOG.json",
]
: [];
async function validateChangelogFile(cwd, raw) {
if (fs.existsSync(path.join(cwd, "CHANGELOG.json"))) {
return [
"CHANGELOG.json is no longer read; move the release notes to CHANGELOG.md",
];
}
let changelog;
try {
changelog = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
} catch (error) {
return [`CHANGELOG.json is not valid JSON: ${error.message}`];
}
return validateChangelog(changelog, raw.version).map(
(problem) => `CHANGELOG.json: ${problem}`,
const mdPath = path.join(cwd, "CHANGELOG.md");
if (!fs.existsSync(mdPath)) return [];
const { validateChangelog } = await loadSdkModule("changelog");
return validateChangelog(fs.readFileSync(mdPath, "utf8"), raw.version).map(
(problem) => `CHANGELOG.md: ${problem}`,
);
}
+3
View File
@@ -14,6 +14,7 @@ import { pack } from "./commands/pack.mjs";
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";
const COMMANDS = {
build,
@@ -30,6 +31,7 @@ const COMMANDS = {
keygen,
migrations,
dev,
changelog,
};
const [command, ...args] = process.argv.slice(2);
@@ -48,6 +50,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",
" 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]",
" (or TERMIX_SERVER_URL and TERMIX_API_KEY; needs developer mode)",
-126
View File
@@ -1,126 +0,0 @@
export const CHANGE_TYPES = [
"added",
"changed",
"fixed",
"removed",
"deprecated",
"security",
];
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
const DATE = /^\d{4}-\d{2}-\d{2}$/;
const TOP_KEYS = ["$schema", "unreleased", "releases"];
const RELEASE_KEYS = ["version", "date", "notes", "changes"];
const CHANGE_KEYS = ["type", "text"];
function compareVersions(a, b) {
const pa = SEMVER.exec(a);
const pb = SEMVER.exec(b);
for (let i = 1; i <= 3; i++) {
const diff = Number(pa[i]) - Number(pb[i]);
if (diff !== 0) return diff;
}
if (pa[4] === pb[4]) return 0;
if (!pa[4]) return 1;
if (!pb[4]) return -1;
return pa[4] < pb[4] ? -1 : 1;
}
function unknownKeys(value, allowed, where, problems) {
for (const key of Object.keys(value)) {
if (!allowed.includes(key))
problems.push(`${where} has unknown key "${key}"`);
}
}
function checkChanges(changes, where, problems) {
if (!Array.isArray(changes)) {
problems.push(`${where} must be an array`);
return;
}
changes.forEach((change, i) => {
const at = `${where}[${i}]`;
if (!change || typeof change !== "object" || Array.isArray(change)) {
problems.push(`${at} must be an object`);
return;
}
unknownKeys(change, CHANGE_KEYS, at, problems);
if (!CHANGE_TYPES.includes(change.type)) {
problems.push(`${at}.type must be one of ${CHANGE_TYPES.join(", ")}`);
}
if (typeof change.text !== "string" || change.text.trim() === "") {
problems.push(`${at}.text must be a non-empty string`);
}
});
}
/**
* Checks a parsed CHANGELOG.json. The newest release has to be the version
* in the manifest, so a release never ships without its notes.
*/
export function validateChangelog(changelog, manifestVersion) {
const problems = [];
if (!changelog || typeof changelog !== "object" || Array.isArray(changelog)) {
return ["CHANGELOG.json must be an object"];
}
unknownKeys(changelog, TOP_KEYS, "CHANGELOG.json", problems);
if (changelog.unreleased !== undefined) {
checkChanges(changelog.unreleased, "unreleased", problems);
}
const releases = changelog.releases;
if (!Array.isArray(releases) || releases.length === 0) {
problems.push("releases must be a non-empty array");
return problems;
}
const seen = new Set();
releases.forEach((release, i) => {
const at = `releases[${i}]`;
if (!release || typeof release !== "object" || Array.isArray(release)) {
problems.push(`${at} must be an object`);
return;
}
unknownKeys(release, RELEASE_KEYS, at, problems);
if (typeof release.version !== "string" || !SEMVER.test(release.version)) {
problems.push(`${at}.version must be a version like 1.2.3`);
} else if (seen.has(release.version)) {
problems.push(`${at}.version ${release.version} is listed twice`);
} else {
seen.add(release.version);
}
if (
release.date !== undefined &&
(typeof release.date !== "string" || !DATE.test(release.date))
) {
problems.push(`${at}.date must be a date like 2026-10-01`);
}
if (release.notes !== undefined && typeof release.notes !== "string") {
problems.push(`${at}.notes must be a string`);
}
checkChanges(release.changes, `${at}.changes`, problems);
if (Array.isArray(release.changes) && release.changes.length === 0) {
problems.push(`${at}.changes must list at least one change`);
}
});
const versions = releases
.map((release) => release?.version)
.filter((v) => typeof v === "string" && SEMVER.test(v));
for (let i = 1; i < versions.length; i++) {
if (compareVersions(versions[i - 1], versions[i]) < 0) {
problems.push("releases must be listed newest first");
break;
}
}
const newest = releases[0]?.version;
if (manifestVersion && newest !== manifestVersion) {
problems.push(
`the newest release is ${newest}, but manifest version is ${manifestVersion}`,
);
}
return problems;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export const TMXPLUG_CONTENTS = [
"locales",
"migrations",
"README.md",
"CHANGELOG.json",
"CHANGELOG.md",
"icon.svg",
];
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.1.0",
"version": "1.2.0",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -49,6 +49,10 @@
"types": "./dist/capabilities.d.ts",
"default": "./dist/capabilities.js"
},
"./changelog": {
"types": "./dist/changelog.d.ts",
"default": "./dist/changelog.js"
},
"./settings": {
"types": "./dist/settings.d.ts",
"default": "./dist/settings.js"
@@ -75,7 +79,6 @@
},
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
"./schema/changelog.schema.json": "./schema/changelog.schema.json",
"./ui": {
"types": "./dist/host/ui.d.ts",
"default": "./dist/host/ui.js"
@@ -1,67 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/Termix-SSH/Termix/main/packages/plugin-sdk/schema/changelog.schema.json",
"title": "Termix plugin changelog",
"description": "Every release of a plugin, newest first. The newest release must match the manifest version.",
"type": "object",
"additionalProperties": false,
"required": ["releases"],
"properties": {
"$schema": { "type": "string" },
"unreleased": {
"description": "Changes that are merged but not released yet.",
"type": "array",
"items": { "$ref": "#/$defs/change" }
},
"releases": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/release" }
}
},
"$defs": {
"release": {
"type": "object",
"additionalProperties": false,
"required": ["version", "changes"],
"properties": {
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$"
},
"date": {
"description": "When the version was released. Leave it out until it ships.",
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"notes": {
"description": "A short line shown above the list of changes.",
"type": "string"
},
"changes": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/change" }
}
}
},
"change": {
"type": "object",
"additionalProperties": false,
"required": ["type", "text"],
"properties": {
"type": {
"enum": [
"added",
"changed",
"fixed",
"removed",
"deprecated",
"security"
]
},
"text": { "type": "string", "minLength": 1 }
}
}
}
}
@@ -74,6 +74,11 @@
"minLength": 1,
"description": "A Lucide icon name, like \"Play\"."
},
"video": {
"type": "string",
"pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/",
"description": "A YouTube link shown at the top of the plugin's page."
},
"engine": {
"type": "object",
"additionalProperties": false,
+249
View File
@@ -0,0 +1,249 @@
/**
* CHANGELOG.md: every release of a plugin (or of core) in one Markdown file.
*
* # Changelog
*
* ## Unreleased
*
* ### Added
* - Something merged but not shipped yet
*
* ## 1.1.0 - 2026-10-06
*
* An optional summary paragraph.
*
* ### Added
* - Something new
*
* ### Fixed
* - Something broken
*
* Releases are listed newest first, and the newest one has to be the
* manifest version. The date is optional until the version ships.
*/
import semver from "semver";
export const CHANGE_TYPES = [
"added",
"changed",
"fixed",
"removed",
"deprecated",
"security",
] as const;
export type ChangeType = (typeof CHANGE_TYPES)[number];
export interface ChangelogChange {
type: ChangeType;
text: string;
}
export interface ChangelogRelease {
version: string;
date?: string;
summary?: string;
changes: ChangelogChange[];
}
export interface Changelog {
unreleased: ChangelogChange[];
/** Newest first, as written. */
releases: ChangelogRelease[];
}
export interface ReleaseNotes {
summary?: string;
changes: ChangelogChange[];
}
const RELEASE_HEADING =
/^##\s+\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-\s+(.+?))?\s*$/;
const UNRELEASED_HEADING = /^##\s+\[?unreleased\]?\s*$/i;
const DATE = /^\d{4}-\d{2}-\d{2}$/;
const BULLET = /^[-*]\s+(.*)$/;
const COMMENT = /^<!--.*-->$/;
/**
* Parses the body under one release heading: an optional summary, then
* `### Added` style sections of bullets. Indented lines continue the bullet
* above them.
*/
export function parseReleaseNotes(
body: string,
where = "release",
): ReleaseNotes & { problems: string[] } {
const problems: string[] = [];
const summary: string[] = [];
const changes: ChangelogChange[] = [];
let section: ChangeType | null = null;
let current: ChangelogChange | null = null;
let fence = false;
for (const raw of body.replace(/\r/g, "").split("\n")) {
const line = raw.trimEnd();
const trimmed = line.trim();
if (trimmed.startsWith("```")) fence = !fence;
if (!fence && trimmed.startsWith("### ")) {
const name = trimmed.slice(4).trim().toLowerCase();
if ((CHANGE_TYPES as readonly string[]).includes(name)) {
section = name as ChangeType;
} else {
problems.push(
`${where} has an unknown section "${trimmed.slice(4).trim()}", use one of ${CHANGE_TYPES.map(title).join(", ")}`,
);
section = null;
}
current = null;
continue;
}
if (!section) {
if (trimmed || summary.length > 0) summary.push(line);
continue;
}
if (!trimmed || COMMENT.test(trimmed)) continue;
const bullet = fence ? null : BULLET.exec(line);
if (bullet) {
current = { type: section, text: bullet[1].trim() };
changes.push(current);
} else if (current && /^\s/.test(line)) {
current.text += `\n${trimmed}`;
} else {
problems.push(
`${where} has text under "### ${title(section)}" that is not a list item: ${trimmed}`,
);
}
}
for (const change of changes) {
if (!change.text) {
problems.push(`${where} has an empty list item`);
}
}
const text = summary.join("\n").trim();
return { ...(text ? { summary: text } : {}), changes, problems };
}
/** Parses a whole CHANGELOG.md. Problems are reported, never thrown. */
export function parseChangelog(markdown: string): {
changelog: Changelog;
problems: string[];
} {
const problems: string[] = [];
const changelog: Changelog = { unreleased: [], releases: [] };
const lines = markdown.replace(/\r/g, "").split("\n");
type Block = { heading: string; line: number; body: string[] };
const blocks: Block[] = [];
let fence = false;
lines.forEach((line, i) => {
if (line.trim().startsWith("```")) fence = !fence;
if (!fence && /^##\s/.test(line)) {
blocks.push({ heading: line.trim(), line: i + 1, body: [] });
} else if (blocks.length > 0) {
blocks[blocks.length - 1].body.push(line);
}
});
const seen = new Set<string>();
blocks.forEach((block, i) => {
if (UNRELEASED_HEADING.test(block.heading)) {
if (i !== 0) problems.push(`"## Unreleased" has to come first`);
const notes = parseReleaseNotes(block.body.join("\n"), "Unreleased");
problems.push(...notes.problems);
changelog.unreleased = notes.changes;
return;
}
const match = RELEASE_HEADING.exec(block.heading);
if (!match) {
problems.push(
`line ${block.line}: "${block.heading}" should look like "## 1.2.0 - 2026-10-06"`,
);
return;
}
const [, version, date] = match;
if (date !== undefined && !DATE.test(date)) {
problems.push(`${version} has a date "${date}", use YYYY-MM-DD`);
}
if (seen.has(version)) problems.push(`${version} is listed twice`);
seen.add(version);
const notes = parseReleaseNotes(block.body.join("\n"), version);
problems.push(...notes.problems);
if (notes.changes.length === 0) {
problems.push(`${version} has no changes listed`);
}
changelog.releases.push({
version,
...(date && DATE.test(date) ? { date } : {}),
...(notes.summary ? { summary: notes.summary } : {}),
changes: notes.changes,
});
});
const versions = changelog.releases.map((r) => r.version);
for (let i = 1; i < versions.length; i++) {
if (semver.lt(versions[i - 1], versions[i])) {
problems.push("releases must be listed newest first");
break;
}
}
return { changelog, problems };
}
/**
* Checks a CHANGELOG.md. With a manifest version, the newest release has to
* be that version, so a release never ships without its notes.
*/
export function validateChangelog(
markdown: string,
manifestVersion?: string,
): string[] {
const { changelog, problems } = parseChangelog(markdown);
if (changelog.releases.length === 0) {
problems.push('no releases found, add a "## 1.0.0" heading');
return problems;
}
const newest = changelog.releases[0].version;
if (manifestVersion && newest !== manifestVersion) {
problems.push(
`the newest release is ${newest}, but the manifest version is ${manifestVersion}`,
);
}
return problems;
}
/**
* The Markdown under one version's heading, as written, for a release body.
* Null when the version is not in the file.
*/
export function changelogSection(
markdown: string,
version: string,
): string | null {
const lines = markdown.replace(/\r/g, "").split("\n");
let start = -1;
let fence = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith("```")) fence = !fence;
if (fence || !/^##\s/.test(line)) continue;
if (start >= 0) {
return lines.slice(start, i).join("\n").trim();
}
const match = RELEASE_HEADING.exec(line.trim());
if (match && match[1] === version) start = i + 1;
}
return start >= 0 ? lines.slice(start).join("\n").trim() : null;
}
function title(type: string): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
+45
View File
@@ -49,6 +49,43 @@ export function isTermixCompatible(
return semver.satisfies(semver.coerce(coreVersion)!.version, range);
}
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
const YOUTUBE_HOSTS = new Set([
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"youtu.be",
"youtube-nocookie.com",
"www.youtube-nocookie.com",
]);
/**
* The video id of a YouTube link, or null for anything else. Only the id is
* ever used, so a manifest can never point the embed at another site.
*/
export function youtubeVideoId(value: unknown): string | null {
if (typeof value !== "string") return null;
let url: URL;
try {
url = new URL(value.trim());
} catch {
return null;
}
if (url.protocol !== "https:" || url.username || url.password) return null;
const host = url.hostname.toLowerCase();
if (!YOUTUBE_HOSTS.has(host)) return null;
let id: string | null | undefined;
if (host === "youtu.be") {
id = url.pathname.slice(1);
} else if (url.pathname === "/watch") {
id = url.searchParams.get("v");
} else {
id = /^\/(?:embed|shorts|live)\/([^/]+)\/?$/.exec(url.pathname)?.[1];
}
return id && YOUTUBE_ID.test(id) ? id : null;
}
export const PLUGIN_CATEGORIES = [
"Terminal",
"Files & Transfer",
@@ -540,6 +577,8 @@ export interface PluginManifest {
repository?: string;
category: string;
icon?: string;
/** A YouTube link shown at the top of the plugin's page. */
video?: string;
engine: PluginEngine;
/** Catalog capability ids. */
capabilities: string[];
@@ -591,6 +630,7 @@ const ALLOWED_TOP_LEVEL = new Set([
...REQUIRED_TOP_LEVEL,
"repository",
"icon",
"video",
"dependencies",
"optionalDependencies",
"provides",
@@ -747,6 +787,11 @@ export function validateManifest(manifest: unknown): string[] {
if ("repository" in m) requireString(m.repository, "repository", errors);
if ("icon" in m) requireString(m.icon, "icon", errors);
if ("video" in m && youtubeVideoId(m.video) === null) {
errors.push(
`Field "video" must be a YouTube link such as https://www.youtube.com/watch?v=..., got: ${JSON.stringify(m.video)}`,
);
}
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);