mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
v26.10.1 (#1599)
* 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:
174 files changed
+94334
-39995
No files matched your search
@@ -92,6 +92,13 @@ Host alias-only
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts tabs and = between keywords and values", () => {
|
||||
const config = "Host\tweb\n\tHostName=10.0.0.5\n\tUser = deploy\n";
|
||||
expect(parseSSHConfig(config)).toEqual([
|
||||
{ name: "web", hostname: "10.0.0.5", user: "deploy" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults port to undefined when not specified", () => {
|
||||
const config = `
|
||||
Host server
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PluginManageError } from "../../../plugins/manage.js";
|
||||
import { RegistryError } from "../../../plugins/registries.js";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
@@ -194,6 +195,19 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const registriesMock = vi.hoisted(() => ({
|
||||
readRegistriesConfig: vi.fn(),
|
||||
previewRegistry: vi.fn(),
|
||||
addRegistry: vi.fn(),
|
||||
setRegistryEnabled: vi.fn(),
|
||||
removeRegistry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/registries.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../../plugins/registries.js")>()),
|
||||
...registriesMock,
|
||||
}));
|
||||
|
||||
const onboardingMock = vi.hoisted(() => ({
|
||||
describeOnboardingPlugins: vi.fn(),
|
||||
applyOnboardingChoices: vi.fn(),
|
||||
@@ -443,6 +457,28 @@ describe("plugins route", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks server-only plugins on a linked desktop", async () => {
|
||||
state.plugins.set(
|
||||
"sample-plugin",
|
||||
makePlugin({ manifestJson: JSON.stringify({ desktop: "server" }) }),
|
||||
);
|
||||
|
||||
const read = async () =>
|
||||
(
|
||||
(await (await fetch(`${baseUrl}/plugins`)).json()) as Array<{
|
||||
runsOnServer: boolean;
|
||||
}>
|
||||
)[0].runsOnServer;
|
||||
|
||||
expect(await read()).toBe(false);
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||
try {
|
||||
expect(await read()).toBe(true);
|
||||
} finally {
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("grants a declared capability", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
|
||||
@@ -577,6 +613,7 @@ describe("plugins route", () => {
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "enabled",
|
||||
runsOnServer: false,
|
||||
contributes: null,
|
||||
dependencies: {},
|
||||
optionalDependencies: {},
|
||||
@@ -744,6 +781,190 @@ describe("plugins route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin registries", () => {
|
||||
const call = (
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
user = "admin-1",
|
||||
) =>
|
||||
fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json", "x-test-user-id": user },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const fn of Object.values(registriesMock)) fn.mockReset();
|
||||
manageMock.isLinkedDesktop.mockReset();
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||
auditMock.logAudit.mockClear();
|
||||
});
|
||||
|
||||
it("is admin only", async () => {
|
||||
for (const [method, path, body] of [
|
||||
["GET", "/plugins/registries", undefined],
|
||||
[
|
||||
"POST",
|
||||
"/plugins/registries/preview",
|
||||
{ url: "https://x.com/i.json" },
|
||||
],
|
||||
["POST", "/plugins/registries", { url: "https://x.com/i.json" }],
|
||||
["PATCH", "/plugins/registries/community", { enabled: true }],
|
||||
["DELETE", "/plugins/registries/acme", undefined],
|
||||
] as const) {
|
||||
const res = await call(method, path, body, "user-2");
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled();
|
||||
expect(registriesMock.removeRegistry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists the registries without the full public keys", async () => {
|
||||
registriesMock.readRegistriesConfig.mockResolvedValue({
|
||||
community: { enabled: true },
|
||||
custom: [
|
||||
{
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
url: "https://acme.example/index.json",
|
||||
publicKey: "secret-looking",
|
||||
keyId: "abcd",
|
||||
enabled: true,
|
||||
addedAt: "2026-10-11T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await call("GET", "/plugins/registries");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.community.enabled).toBe(true);
|
||||
expect(body.custom[0]).toEqual({
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "abcd",
|
||||
enabled: true,
|
||||
addedAt: "2026-10-11T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("previews and adds a registry and audits the add", async () => {
|
||||
registriesMock.previewRegistry.mockResolvedValue({
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "abcd",
|
||||
plugins: 2,
|
||||
});
|
||||
const preview = await call("POST", "/plugins/registries/preview", {
|
||||
url: "https://acme.example/index.json",
|
||||
});
|
||||
expect(preview.status).toBe(200);
|
||||
expect((await preview.json()).keyId).toBe("abcd");
|
||||
|
||||
registriesMock.addRegistry.mockResolvedValue({
|
||||
id: "acme",
|
||||
name: "Acme",
|
||||
url: "https://acme.example/index.json",
|
||||
publicKey: "k",
|
||||
keyId: "abcd",
|
||||
enabled: true,
|
||||
addedAt: "2026-10-11T00:00:00Z",
|
||||
});
|
||||
const added = await call("POST", "/plugins/registries", {
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "abcd",
|
||||
});
|
||||
expect(added.status).toBe(200);
|
||||
expect(registriesMock.addRegistry).toHaveBeenCalledWith(
|
||||
"https://acme.example/index.json",
|
||||
"abcd",
|
||||
);
|
||||
expect(auditMock.logAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "add_plugin_registry",
|
||||
resourceType: "plugin_registry",
|
||||
resourceId: "acme",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes registry errors through with their code", async () => {
|
||||
registriesMock.addRegistry.mockRejectedValue(
|
||||
new RegistryError("changed", 409, "KEY_MISMATCH", { keyId: "new" }),
|
||||
);
|
||||
const res = await call("POST", "/plugins/registries", {
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "old",
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect(await res.json()).toEqual({
|
||||
error: "changed",
|
||||
code: "KEY_MISMATCH",
|
||||
keyId: "new",
|
||||
});
|
||||
});
|
||||
|
||||
it("turns a registry on and off and checks the body", async () => {
|
||||
expect(
|
||||
(await call("PATCH", "/plugins/registries/community", { enabled: "x" }))
|
||||
.status,
|
||||
).toBe(400);
|
||||
const res = await call("PATCH", "/plugins/registries/community", {
|
||||
enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(registriesMock.setRegistryEnabled).toHaveBeenCalledWith(
|
||||
"community",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to remove a registry still in use", async () => {
|
||||
registriesMock.removeRegistry.mockRejectedValue(
|
||||
new RegistryError("in use", 409, "REGISTRY_IN_USE", {
|
||||
plugins: ["tool"],
|
||||
}),
|
||||
);
|
||||
const res = await call("DELETE", "/plugins/registries/acme");
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).plugins).toEqual(["tool"]);
|
||||
});
|
||||
|
||||
it("leaves registries to the server on a linked desktop", async () => {
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||
const res = await call("PATCH", "/plugins/registries/community", {
|
||||
enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs from the registry the caller names", async () => {
|
||||
manageMock.installPlugin.mockReset();
|
||||
manageMock.installPlugin.mockResolvedValue({
|
||||
id: "neat",
|
||||
version: "1.0.0",
|
||||
state: "active",
|
||||
});
|
||||
const res = await call("POST", "/plugins/neat/install", {
|
||||
registryId: "community",
|
||||
capabilities: [],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(manageMock.installPlugin).toHaveBeenCalledWith(
|
||||
"neat",
|
||||
expect.objectContaining({ registryId: "community" }),
|
||||
);
|
||||
const bad = await call("POST", "/plugins/neat/install", {
|
||||
registryId: "../x",
|
||||
capabilities: [],
|
||||
});
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("install, update, uninstall", () => {
|
||||
const post = (path: string, body: unknown = {}, user = "admin-1") =>
|
||||
fetch(`${baseUrl}${path}`, {
|
||||
|
||||
Reference in new issue
Block a user