* feat: AI translations at release, 7 new languages and regional fallback

* fix: proper plural forms for count strings and full translations

* fix: plural forms for selected, hidden and active counts

* fix: synced plugin updates failing on desktop and server-only plugins shown as stopped

* fix: restore rounded corners on app icons and favicon

* feat: desktop app auto updates with automatic, ask first or never modes

* feat: community and custom plugin registries

* feat: plugin guide for AI tools in the SDK with a lint check

* fix: keep the terminal WebSocket open past nginx's 60s default (#1588)

* chore: update changelog

* feat: show plugin download counts only and stop CI builds inflating them

* feat: hide installed plugins in the browse tab by default

* chore: pin bundled plugins to latest releases

* chore: update translations

* fix: focusing a saved password field and saving no longer erases it

* fix: tell desktop users when their 2.8 data needs 2.9 first

* fix: point windows update info at the x64 installer

* fix: keep desktop client tunnel sockets alive behind proxies

* chore: pin bundled plugins to latest releases

* fix: cover client tunnel keepalive in the c2s runtime tests

* fix: typing after opening a host no longer goes to the host search

* fix: accept tab and = separators in SSH config import (#1598)

* chore: update changelog

* chore: drop plugin versions from core changelog

* chore: update translations

* chore: lint, format, and bump version to 26.10.1

---------

Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com>
Co-authored-by: Iain <Iain1970@protonmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 11:35:48 -05:00
1 parent 6873bc92bb
commit 62ac931468
174 files changed
+94334 -39995

No files matched your search

+113 -14
View File
@@ -17,14 +17,27 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
await importOriginal<typeof import("../../plugins/trust.js")>();
return {
...actual,
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
actual.verifyPluginArtifact(buffer, sha256, signature, [
testKey.value as never,
]),
// The built in official keys are swapped for the test key. Keys a
// caller passes for another registry are used as they are.
verifyPluginArtifact: (
buffer: Buffer,
sha256: string,
signature: string,
keys?: readonly unknown[],
) =>
actual.verifyPluginArtifact(
buffer,
sha256,
signature,
keys && keys !== actual.TRUSTED_PLUGIN_KEYS
? (keys as never)
: [testKey.value as never],
),
};
});
import {
communitySource,
downloadRelease,
fetchRegistryIndex,
fetchRegistryStats,
@@ -35,6 +48,7 @@ import {
parseRegistryIndex,
parseRegistryStats,
resetRegistryCache,
type RegistrySource,
type RegistryVersion,
} from "../../plugins/registry-index.js";
@@ -91,6 +105,25 @@ afterEach(() => {
fs.rmSync(dataDir, { recursive: true, force: true });
});
describe("parseRegistryIndex publicKey", () => {
const key = publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64");
it("keeps a valid key and drops anything else", () => {
expect(parseRegistryIndex({ publicKey: key, plugins: [] }).publicKey).toBe(
key,
);
expect(
parseRegistryIndex({ publicKey: "not a key", plugins: [] }).publicKey,
).toBeUndefined();
expect(
parseRegistryIndex({ publicKey: 42, plugins: [] }).publicKey,
).toBeUndefined();
});
});
describe("parseRegistryIndex", () => {
it("keeps valid entries and sorts versions newest first", () => {
const index = parseRegistryIndex({
@@ -289,7 +322,29 @@ describe("fetchRegistryIndex", () => {
fetchMock.mockResolvedValueOnce(response(body));
const first = await fetchRegistryIndex();
fetchMock.mockResolvedValueOnce(response("down", 503));
expect(await fetchRegistryIndex({ force: true })).toBe(first);
expect(await fetchRegistryIndex(undefined, { force: true })).toBe(first);
});
it("caches each registry on its own", async () => {
const other: RegistrySource = {
id: "mine",
name: "Mine",
url: "https://example.com/mine/index.json",
tier: "custom",
keys: [],
};
fetchMock
.mockResolvedValueOnce(response(body))
.mockResolvedValueOnce(
response(JSON.stringify({ registry: "mine", plugins: [] })),
);
const official = await fetchRegistryIndex();
const mine = await fetchRegistryIndex(other);
expect(official.plugins).toHaveLength(1);
expect(mine.plugins).toHaveLength(0);
expect(fetchMock.mock.calls[1][0]).toBe(other.url);
expect(await fetchRegistryIndex(other)).toBe(mine);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("throws when there is nothing cached", async () => {
@@ -384,6 +439,38 @@ describe("downloadRelease", () => {
).rejects.toThrow(/sha256/);
});
it("checks against the keys of the registry it came from", async () => {
const signed = sign(artifact);
const own = crypto.generateKeyPairSync("ed25519");
const ownKey = {
id: "own",
publicKey: own.publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "admin",
};
fetchMock.mockResolvedValueOnce(response(artifact));
await expect(
downloadRelease("docker", version({ ...signed, size: artifact.length }), [
ownKey,
]),
).rejects.toThrow(/trusted key/);
const digest = crypto.createHash("sha256").update(artifact).digest();
fetchMock.mockResolvedValueOnce(response(artifact));
const result = await downloadRelease(
"docker",
version({
sha256: digest.toString("hex"),
signature: crypto.sign(null, digest, own.privateKey).toString("base64"),
size: artifact.length,
}),
[ownKey],
);
expect(fs.readFileSync(result.file)).toEqual(artifact);
});
it("refuses a release built for another plugin API", async () => {
await expect(
downloadRelease("docker", version({ api: "2" })),
@@ -407,19 +494,34 @@ describe("registry stats", () => {
);
});
it("has a community stats file and none for custom registries", () => {
expect(getStatsUrl(communitySource())).toBe(
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/stats.json",
);
expect(
getStatsUrl({
id: "mine",
name: "Mine",
url: "https://example.com/index.json",
tier: "custom",
keys: [],
}),
).toBeNull();
});
it("keeps valid counts and drops the rest", () => {
const stats = parseRegistryStats({
plugins: {
docker: { downloads: 120, activeInstalls: 40 },
docker: { downloads: 120 },
tunnels: { downloads: 7 },
"../bad": { downloads: 1 },
broken: { downloads: -3, activeInstalls: "many" },
broken: { downloads: -3 },
},
});
expect(Object.fromEntries(stats.plugins)).toEqual({
docker: { downloads: 120, activeInstalls: 40 },
tunnels: { downloads: 7, activeInstalls: null },
broken: { downloads: 0, activeInstalls: null },
docker: 120,
tunnels: 7,
broken: 0,
});
});
@@ -428,9 +530,6 @@ describe("registry stats", () => {
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
);
const stats = await fetchRegistryStats();
expect(stats?.plugins.get("docker")).toEqual({
downloads: 3,
activeInstalls: null,
});
expect(stats?.plugins.get("docker")).toBe(3);
});
});