feat: plugin filters by type, paged browse, hide plugins needing newer termix

This commit is contained in:
LukeGus committed 2026-10-18 20:00:47 -05:00
1 parent 556f0406aa
commit 9d93fb0893
14 files changed
+1069 -223

No files matched your search

+22 -4
View File
@@ -34,6 +34,7 @@ import {
} from "./paths.js";
import {
OFFICIAL_REGISTRY_ID,
canRunRelease,
downloadRelease,
fetchRegistryIndex,
channelReleases,
@@ -42,6 +43,7 @@ import {
getRegistryStatus,
isApiCompatible,
latestOnChannel,
neededTermix,
officialSource,
parsePluginChannel,
type PluginChannel,
@@ -257,10 +259,13 @@ export interface RegistryListing {
videoId?: string;
features: string[];
docs?: string;
contributes: string[];
versions: Array<{
version: string;
prerelease: boolean;
compatible: boolean;
/** The Termix range this version needs, when this build is too old. */
needsTermix: string | null;
capabilities: string[];
publishedAt?: string;
releaseNotesUrl?: string;
@@ -396,7 +401,7 @@ export async function listRegistry(
const channel = channelOf(record);
const latest = latestOnChannel(plugin, channel);
const latestBeta =
plugin.prereleases.find((entry) => isApiCompatible(entry.api)) ?? null;
plugin.prereleases.find((entry) => canRunRelease(entry)) ?? null;
const installedVersion =
live?.manifest.version ?? record?.version ?? null;
const updateAvailable = Boolean(
@@ -419,10 +424,12 @@ export async function listRegistry(
videoId: plugin.videoId,
features: plugin.features,
docs: plugin.docs,
contributes: plugin.contributes,
versions: channelReleases(plugin, "beta").map((entry) => ({
version: entry.version,
prerelease: Boolean(entry.prerelease),
compatible: isApiCompatible(entry.api),
compatible: canRunRelease(entry),
needsTermix: neededTermix(entry),
capabilities: entry.capabilities,
publishedAt: entry.publishedAt,
releaseNotesUrl: entry.releaseNotesUrl,
@@ -902,9 +909,12 @@ async function requireRelease(
"NOT_IN_REGISTRY",
);
}
if (!isApiCompatible(found.release.api)) {
if (!canRunRelease(found.release)) {
const termix = neededTermix(found.release);
throw new PluginManageError(
`${pluginId} ${found.release.version} does not run on this version of Termix`,
termix
? `${pluginId} ${found.release.version} needs Termix ${termix}`
: `${pluginId} ${found.release.version} does not run on this version of Termix`,
409,
"INCOMPATIBLE",
);
@@ -1502,6 +1512,14 @@ function validateUpload(buffer: Buffer): PluginManifest {
"INCOMPATIBLE",
);
}
const termix = neededTermix({ engine: manifest.engine });
if (termix) {
throw new PluginManageError(
`${manifest.id} ${manifest.version} needs Termix ${termix}`,
409,
"INCOMPATIBLE",
);
}
return manifest;
}
+60 -9
View File
@@ -15,11 +15,14 @@ import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import {
isApiCompatible as satisfiesPluginApi,
isTermixCompatible,
pluginDocsUrl,
pluginFeatures,
SUPPORTED_PLUGIN_API_VERSION,
youtubeVideoId,
} from "@termix-ssh/plugin-sdk/manifest";
import { getLocalVersion } from "../utils/app-version.js";
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
import { pluginLogger } from "../utils/logger.js";
import {
@@ -59,7 +62,10 @@ const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
export interface RegistryVersion {
version: string;
/** The API major, all that builds before engine ranges read. */
api: string;
/** The manifest's engine ranges, when the index lists them. */
engine?: { api: string; termix: string };
url: string;
sha256: string;
signature: string;
@@ -92,6 +98,8 @@ export interface RegistryPlugin {
features: string[];
/** https docs link from the index. */
docs?: string;
/** The manifest's contributes keys, like layouts, themes or tabs. */
contributes: string[];
/** Stable releases, newest first. */
versions: RegistryVersion[];
/**
@@ -149,6 +157,9 @@ function parseVersion(raw: unknown): RegistryVersion | null {
return null;
}
if (!/^[a-f0-9]{64}$/i.test(sha256)) return null;
const engine = entry.engine as Record<string, unknown> | undefined;
const engineApi = asString(engine?.api);
const engineTermix = asString(engine?.termix);
try {
if (new URL(url).protocol !== "https:") return null;
} catch {
@@ -157,6 +168,9 @@ function parseVersion(raw: unknown): RegistryVersion | null {
return {
version,
api: asString(entry.api) ?? "",
...(engineApi && engineTermix
? { engine: { api: engineApi, termix: engineTermix } }
: {}),
url,
sha256: sha256.toLowerCase(),
signature,
@@ -191,11 +205,15 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
.map(parseVersion)
.filter((v): v is RegistryVersion => v !== null)
.sort((a, b) => semver.rcompare(a.version, b.version));
const versions = parseList(entry.versions).filter(
(v) => !semver.prerelease(v.version),
);
const prereleases = parseList(entry.prereleases)
// gated holds releases that need a newer Termix than builds which skip
// the engine.termix check, so only builds that check it read the list.
const gated = parseList(entry.gated);
const versions = [...parseList(entry.versions), ...gated]
.filter((v) => !semver.prerelease(v.version))
.sort((a, b) => semver.rcompare(a.version, b.version));
const prereleases = [...parseList(entry.prereleases), ...gated]
.filter((v) => semver.prerelease(v.version))
.sort((a, b) => semver.rcompare(a.version, b.version))
.map((v) => ({ ...v, prerelease: true }));
if (versions.length === 0 && prereleases.length === 0) continue;
plugins.push({
@@ -209,6 +227,14 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
videoId: youtubeVideoId(entry.video) ?? undefined,
features: pluginFeatures(entry.features),
docs: pluginDocsUrl(entry.docs) ?? undefined,
contributes: Array.isArray(entry.contributes)
? entry.contributes
.filter(
(kind): kind is string =>
typeof kind === "string" && /^[A-Za-z]+$/.test(kind),
)
.slice(0, 50)
: [],
versions,
prereleases,
});
@@ -229,6 +255,29 @@ export function isApiCompatible(api: string): boolean {
return major !== undefined && String(major) === SUPPORTED_PLUGIN_API_VERSION;
}
/**
* The Termix range a release needs when this build is too old for it, or
* null when this build satisfies it.
*/
export function neededTermix(
release: Pick<RegistryVersion, "engine">,
): string | null {
const range = release.engine?.termix;
if (!range) return null;
return isTermixCompatible(range, getLocalVersion()) ? null : range;
}
/** Whether this build can run a release: its API and its engine ranges. */
export function canRunRelease(
release: Pick<RegistryVersion, "api" | "engine">,
): boolean {
if (!isApiCompatible(release.api)) return false;
if (!release.engine) return true;
return (
satisfiesPluginApi(release.engine.api) && neededTermix(release) === null
);
}
/** Follows redirects by hand so each hop still gets the outbound checks. */
async function fetchFollowing(url: string): Promise<Response> {
let current = url;
@@ -427,9 +476,8 @@ export function latestOnChannel(
channel: PluginChannel,
): RegistryVersion | null {
return (
channelReleases(plugin, channel).find((entry) =>
isApiCompatible(entry.api),
) ?? null
channelReleases(plugin, channel).find((entry) => canRunRelease(entry)) ??
null
);
}
@@ -486,9 +534,12 @@ export async function downloadRelease(
release: RegistryVersion,
keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS,
): Promise<{ file: string; signatureFile: string }> {
if (!isApiCompatible(release.api)) {
if (!canRunRelease(release)) {
const termix = neededTermix(release);
throw new Error(
`${pluginId} ${release.version} needs plugin API ${release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`,
termix
? `${pluginId} ${release.version} needs Termix ${termix}, this is ${getLocalVersion()}`
: `${pluginId} ${release.version} needs plugin API ${release.engine?.api ?? release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`,
);
}
@@ -12,6 +12,10 @@ vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: fetchMock,
}));
vi.mock("../../utils/app-version.js", () => ({
getLocalVersion: () => "26.10.3",
}));
vi.mock("../../plugins/trust.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../plugins/trust.js")>();
@@ -44,7 +48,9 @@ import {
findRelease,
latestOnChannel,
getStatsUrl,
canRunRelease,
isApiCompatible,
neededTermix,
parseRegistryIndex,
parseRegistryStats,
resetRegistryCache,
@@ -239,6 +245,56 @@ describe("findRelease", () => {
});
});
describe("engine ranges", () => {
const engine = (termix: string, api = "^1.0") => ({ api, termix });
it("reads gated releases into versions and prereleases", () => {
const index = parseRegistryIndex({
plugins: [
{
id: "classic-layout",
versions: [],
gated: [
version({ version: "1.1.0-beta.1", engine: engine(">=26.10.3") }),
version({ version: "1.0.0", engine: engine(">=26.10.3") }),
],
contributes: ["layouts", "tabs", "not valid"],
},
],
});
const [plugin] = index.plugins;
expect(plugin.versions.map((v) => v.version)).toEqual(["1.0.0"]);
expect(plugin.prereleases.map((v) => v.version)).toEqual(["1.1.0-beta.1"]);
expect(plugin.contributes).toEqual(["layouts", "tabs"]);
});
it("checks engine.termix and the full engine.api", () => {
expect(canRunRelease(version({ engine: engine(">=26.10.3") }))).toBe(true);
expect(canRunRelease(version({ engine: engine(">=26.11.0") }))).toBe(false);
expect(neededTermix(version({ engine: engine(">=26.11.0") }))).toBe(
">=26.11.0",
);
expect(neededTermix(version({ engine: engine(">=26.10.0") }))).toBeNull();
expect(
canRunRelease(version({ engine: engine(">=26.10.0", "^1.99") })),
).toBe(false);
expect(canRunRelease(version())).toBe(true);
});
it("skips a release this build is too old for", () => {
const index = parseRegistryIndex({
plugins: [
{
id: "docker",
versions: [version({ version: "1.0.0" })],
gated: [version({ version: "1.1.0", engine: engine(">=26.11.0") })],
},
],
});
expect(findRelease(index, "docker")?.release.version).toBe("1.0.0");
});
});
describe("prereleases", () => {
const index = parseRegistryIndex({
plugins: [