mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: plugin filters by type, paged browse, hide plugins needing newer termix
This commit is contained in:
1 parent
556f0406aa
commit
9d93fb0893
14 files changed
+1069
-223
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in new issue
Block a user