mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
fix: synced plugin updates failing on desktop and server-only plugins shown as stopped
This commit is contained in:
1 parent
e92db4e9f8
commit
91e1ee94fa
12 files changed
+261
-12
No files matched your search
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
- Regional browser languages like `es-MX`, `pt` or `zh-HK` showed English instead of the closest translation
|
- Regional browser languages like `es-MX`, `pt` or `zh-HK` showed English instead of the closest translation
|
||||||
- Counts like "1 keys" or "Moved 1 host(s)" now use the singular
|
- Counts like "1 keys" or "Moved 1 host(s)" now use the singular
|
||||||
|
- Plugin updates synced to the desktop app failed to start with "Cannot find package"
|
||||||
|
- The desktop app showed plugins that only run on the server as not running and out of date
|
||||||
|
|
||||||
## 26.10.0
|
## 26.10.0
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import { describePluginFrontend } from "../../plugins/assets.js";
|
|||||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||||
import { MAX_UPLOAD_BYTES, PluginManageError } from "../../plugins/manage.js";
|
import {
|
||||||
|
isLinkedDesktop,
|
||||||
|
MAX_UPLOAD_BYTES,
|
||||||
|
PluginManageError,
|
||||||
|
} from "../../plugins/manage.js";
|
||||||
import { parsePluginChannel } from "../../plugins/registry-index.js";
|
import { parsePluginChannel } from "../../plugins/registry-index.js";
|
||||||
import {
|
import {
|
||||||
pluginDocsUrl,
|
pluginDocsUrl,
|
||||||
@@ -236,6 +240,9 @@ export async function listPreLoginPlugins(): Promise<
|
|||||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||||
* `dependencies` and `optionalDependencies` let the loader activate
|
* `dependencies` and `optionalDependencies` let the loader activate
|
||||||
* frontends in the same order the server does.
|
* frontends in the same order the server does.
|
||||||
|
*
|
||||||
|
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
||||||
|
* only run on that server and stay off here on purpose.
|
||||||
* tags:
|
* tags:
|
||||||
* - Plugins
|
* - Plugins
|
||||||
* responses:
|
* responses:
|
||||||
@@ -253,6 +260,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
userId,
|
userId,
|
||||||
"admin.plugins.manage",
|
"admin.plugins.manage",
|
||||||
);
|
);
|
||||||
|
const linked = await isLinkedDesktop();
|
||||||
|
|
||||||
// One query for every plugin rather than one per plugin.
|
// One query for every plugin rather than one per plugin.
|
||||||
const grantsByPlugin = new Map<string, string[]>();
|
const grantsByPlugin = new Map<string, string[]>();
|
||||||
@@ -285,6 +293,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
let features: string[] = [];
|
let features: string[] = [];
|
||||||
let docs: string | undefined;
|
let docs: string | undefined;
|
||||||
let env: PluginEnvVar[] = [];
|
let env: PluginEnvVar[] = [];
|
||||||
|
let desktopMode: unknown = loaded?.manifest.desktop;
|
||||||
try {
|
try {
|
||||||
const manifest = JSON.parse(record.manifestJson) as {
|
const manifest = JSON.parse(record.manifestJson) as {
|
||||||
contributes?: unknown;
|
contributes?: unknown;
|
||||||
@@ -299,7 +308,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
env?: unknown;
|
env?: unknown;
|
||||||
dependencies?: Record<string, string>;
|
dependencies?: Record<string, string>;
|
||||||
optionalDependencies?: Record<string, string>;
|
optionalDependencies?: Record<string, string>;
|
||||||
|
desktop?: unknown;
|
||||||
};
|
};
|
||||||
|
desktopMode = loaded?.manifest.desktop ?? manifest?.desktop;
|
||||||
contributes = manifest?.contributes ?? null;
|
contributes = manifest?.contributes ?? null;
|
||||||
capabilities = Array.isArray(manifest?.capabilities)
|
capabilities = Array.isArray(manifest?.capabilities)
|
||||||
? (manifest.capabilities as string[])
|
? (manifest.capabilities as string[])
|
||||||
@@ -332,6 +343,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
version: record.version,
|
version: record.version,
|
||||||
enabled: record.state === "enabled",
|
enabled: record.state === "enabled",
|
||||||
state: loaded?.state ?? record.state,
|
state: loaded?.state ?? record.state,
|
||||||
|
runsOnServer: linked && desktopMode === "server",
|
||||||
contributes,
|
contributes,
|
||||||
icon,
|
icon,
|
||||||
dependencies,
|
dependencies,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import module from "node:module";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { getPluginsDir } from "./paths.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plugin build leaves host packages (drizzle-orm, express, ssh2, ...) for
|
||||||
|
* core to supply, and Node finds them by walking up from the importing file.
|
||||||
|
* That works for bundled plugins inside the app, but a .tmxplug unpacked into
|
||||||
|
* a data dir outside it (the desktop app keeps it in AppData) finds nothing.
|
||||||
|
* Those imports fall back to core's own copy, the one bundled plugins get.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let installed = false;
|
||||||
|
|
||||||
|
export function isBareSpecifier(specifier: string): boolean {
|
||||||
|
if (specifier.startsWith(".") || specifier.startsWith("/")) return false;
|
||||||
|
if (specifier.startsWith("#")) return false;
|
||||||
|
// file:, node:, data:, and a Windows drive letter
|
||||||
|
return !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an import from parentURL may fall back to core's packages. */
|
||||||
|
export function isUserPluginFile(
|
||||||
|
parentURL: string | undefined,
|
||||||
|
pluginsDir: string,
|
||||||
|
): boolean {
|
||||||
|
if (!parentURL?.startsWith("file:")) return false;
|
||||||
|
let file: string;
|
||||||
|
try {
|
||||||
|
file = fileURLToPath(new URL(parentURL));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const relative = path.relative(path.resolve(pluginsDir), file);
|
||||||
|
return (
|
||||||
|
relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installHostModuleFallback(): void {
|
||||||
|
if (installed) return;
|
||||||
|
installed = true;
|
||||||
|
// Node 22.15+. Older runtimes keep plain resolution.
|
||||||
|
if (typeof module.registerHooks !== "function") return;
|
||||||
|
module.registerHooks({
|
||||||
|
resolve(specifier, context, nextResolve) {
|
||||||
|
try {
|
||||||
|
return nextResolve(specifier, context);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
(error as { code?: string })?.code !== "ERR_MODULE_NOT_FOUND" ||
|
||||||
|
!isBareSpecifier(specifier) ||
|
||||||
|
!isUserPluginFile(context.parentURL, getPluginsDir())
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return nextResolve(specifier, {
|
||||||
|
...context,
|
||||||
|
parentURL: import.meta.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import { resolveRequirements } from "./service-registry.js";
|
import { resolveRequirements } from "./service-registry.js";
|
||||||
import { resolveSecretRequirements } from "./secret-registry.js";
|
import { resolveSecretRequirements } from "./secret-registry.js";
|
||||||
import { recordConflict } from "./conflicts.js";
|
import { recordConflict } from "./conflicts.js";
|
||||||
|
import { installHostModuleFallback } from "./host-modules.js";
|
||||||
import { tablePrefix } from "@termix-ssh/plugin-sdk/db";
|
import { tablePrefix } from "@termix-ssh/plugin-sdk/db";
|
||||||
|
|
||||||
export type PluginState =
|
export type PluginState =
|
||||||
@@ -567,6 +568,7 @@ export class PluginLoader {
|
|||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
installHostModuleFallback();
|
||||||
const imported = (await import(entry)) as {
|
const imported = (await import(entry)) as {
|
||||||
activate?: PluginModule["activate"];
|
activate?: PluginModule["activate"];
|
||||||
deactivate?: PluginModule["deactivate"];
|
deactivate?: PluginModule["deactivate"];
|
||||||
|
|||||||
@@ -443,6 +443,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 () => {
|
it("grants a declared capability", async () => {
|
||||||
state.plugins.set("sample-plugin", makePlugin());
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
|
|
||||||
@@ -577,6 +599,7 @@ describe("plugins route", () => {
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
state: "enabled",
|
state: "enabled",
|
||||||
|
runsOnServer: false,
|
||||||
contributes: null,
|
contributes: null,
|
||||||
dependencies: {},
|
dependencies: {},
|
||||||
optionalDependencies: {},
|
optionalDependencies: {},
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isBareSpecifier,
|
||||||
|
isUserPluginFile,
|
||||||
|
} from "../../plugins/host-modules.js";
|
||||||
|
|
||||||
|
describe("isBareSpecifier", () => {
|
||||||
|
it("accepts package names", () => {
|
||||||
|
expect(isBareSpecifier("drizzle-orm")).toBe(true);
|
||||||
|
expect(isBareSpecifier("drizzle-orm/sqlite-core")).toBe(true);
|
||||||
|
expect(isBareSpecifier("@termix-ssh/plugin-sdk/backend")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses paths, urls and builtins", () => {
|
||||||
|
expect(isBareSpecifier("./local.js")).toBe(false);
|
||||||
|
expect(isBareSpecifier("../up.js")).toBe(false);
|
||||||
|
expect(isBareSpecifier("/abs/file.js")).toBe(false);
|
||||||
|
expect(isBareSpecifier("file:///x.js")).toBe(false);
|
||||||
|
expect(isBareSpecifier("node:fs")).toBe(false);
|
||||||
|
expect(isBareSpecifier("C:\\x\\y.js")).toBe(false);
|
||||||
|
expect(isBareSpecifier("#internal")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isUserPluginFile", () => {
|
||||||
|
const root = path.resolve(os.tmpdir(), "termix-data", "plugins");
|
||||||
|
|
||||||
|
it("matches files under the plugins dir, query included", () => {
|
||||||
|
const file = path.join(root, ".unpacked", "demo", "dist", "backend.js");
|
||||||
|
expect(isUserPluginFile(`${pathToFileURL(file).href}?v=1`, root)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores everything else", () => {
|
||||||
|
const outside = path.resolve(os.tmpdir(), "termix-data", "other.js");
|
||||||
|
expect(isUserPluginFile(pathToFileURL(outside).href, root)).toBe(false);
|
||||||
|
expect(isUserPluginFile(pathToFileURL(root).href, root)).toBe(false);
|
||||||
|
expect(isUserPluginFile(undefined, root)).toBe(false);
|
||||||
|
expect(isUserPluginFile("data:text/javascript,1", root)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("installHostModuleFallback", () => {
|
||||||
|
let dataDir: string | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (dataDir) fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
dataDir = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// A real Node process: vitest resolves imports on its own and would hide
|
||||||
|
// what plain Node does.
|
||||||
|
function importFromPlugin(withFallback: boolean) {
|
||||||
|
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-host-modules-"));
|
||||||
|
const dir = path.join(dataDir, "plugins", ".unpacked", "demo");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const entry = path.join(dir, "backend.mjs");
|
||||||
|
fs.writeFileSync(
|
||||||
|
entry,
|
||||||
|
'import { sql } from "drizzle-orm";\nconsole.log(typeof sql);\n',
|
||||||
|
);
|
||||||
|
const hook = pathToFileURL(
|
||||||
|
path.resolve(__dirname, "../../plugins/host-modules.ts"),
|
||||||
|
).href;
|
||||||
|
const script = [
|
||||||
|
`const { installHostModuleFallback } = await import(${JSON.stringify(hook)});`,
|
||||||
|
withFallback ? "installHostModuleFallback();" : "",
|
||||||
|
`await import(${JSON.stringify(`${pathToFileURL(entry).href}?v=1`)});`,
|
||||||
|
].join("\n");
|
||||||
|
return spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
["--import", "tsx", "--input-type=module", "-e", script],
|
||||||
|
{
|
||||||
|
cwd: path.resolve(__dirname, "../../../.."),
|
||||||
|
env: { ...process.env, DATA_DIR: dataDir },
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lets a plugin outside the app import core's packages", () => {
|
||||||
|
const result = importFromPlugin(true);
|
||||||
|
expect(result.stderr).toBe("");
|
||||||
|
expect(result.stdout.trim()).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails the same import without it", () => {
|
||||||
|
const result = importFromPlugin(false);
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain("Cannot find package 'drizzle-orm'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -221,6 +221,8 @@ export interface PluginSummary {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** enabled | disabled | blocked | failed, or the loader's live state. */
|
/** enabled | disabled | blocked | failed, or the loader's live state. */
|
||||||
state: string;
|
state: string;
|
||||||
|
/** A linked desktop leaves this plugin to its server. */
|
||||||
|
runsOnServer?: boolean;
|
||||||
contributes: PluginContributions | null;
|
contributes: PluginContributions | null;
|
||||||
/** Lucide icon name from the manifest. */
|
/** Lucide icon name from the manifest. */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
|||||||
@@ -1875,12 +1875,15 @@
|
|||||||
"running": "Running",
|
"running": "Running",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"failed": "Stopped unexpectedly",
|
"failed": "Stopped unexpectedly",
|
||||||
"blocked": "Waiting on another plugin"
|
"blocked": "Waiting on another plugin",
|
||||||
|
"server": "Runs on your server"
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"running": "Running",
|
"running": "Running",
|
||||||
"notRunning": "Not running"
|
"notRunning": "Not running",
|
||||||
|
"onServer": "On your server"
|
||||||
},
|
},
|
||||||
|
"runsOnServerHint": "This plugin only runs on your server, so it stays off in the desktop app. It still works there.",
|
||||||
"sort": {
|
"sort": {
|
||||||
"popular": "Most popular",
|
"popular": "Most popular",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Lightbulb,
|
Lightbulb,
|
||||||
Pin,
|
Pin,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Trash2,
|
Trash2,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
@@ -225,6 +226,15 @@ export function PluginDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{plugin.status === "server" && (
|
||||||
|
<div className="flex items-start gap-2 border border-border bg-muted/30 px-3 py-2.5">
|
||||||
|
<Server className="mt-px size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||||
|
{t("plugins.manager.runsOnServerHint")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||||
<div className="flex flex-col gap-2 lg:col-span-2">
|
<div className="flex flex-col gap-2 lg:col-span-2">
|
||||||
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
|
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
|
||||||
|
|||||||
@@ -483,7 +483,10 @@ function InstalledSection({
|
|||||||
sort,
|
sort,
|
||||||
);
|
);
|
||||||
const running = rows.filter((p) => p.status === "running");
|
const running = rows.filter((p) => p.status === "running");
|
||||||
const stopped = rows.filter((p) => p.status !== "running");
|
const onServer = rows.filter((p) => p.status === "server");
|
||||||
|
const stopped = rows.filter(
|
||||||
|
(p) => p.status !== "running" && p.status !== "server",
|
||||||
|
);
|
||||||
const pending = plugins.filter((p) => p.updateAvailable && !p.pinnedVersion);
|
const pending = plugins.filter((p) => p.updateAvailable && !p.pinnedVersion);
|
||||||
|
|
||||||
const listColumns: DataColumn<PluginEntry>[] = [
|
const listColumns: DataColumn<PluginEntry>[] = [
|
||||||
@@ -587,6 +590,14 @@ function InstalledSection({
|
|||||||
{collection(running)}
|
{collection(running)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
{onServer.length > 0 && (
|
||||||
|
<Group
|
||||||
|
title={t("plugins.manager.groups.onServer")}
|
||||||
|
count={onServer.length}
|
||||||
|
>
|
||||||
|
{collection(onServer)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
{stopped.length > 0 && (
|
{stopped.length > 0 && (
|
||||||
<Group
|
<Group
|
||||||
title={t("plugins.manager.groups.notRunning")}
|
title={t("plugins.manager.groups.notRunning")}
|
||||||
@@ -717,7 +728,9 @@ function InstalledCard({
|
|||||||
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
||||||
<span className="size-1.5 shrink-0 bg-muted-foreground/40" />
|
<span className="size-1.5 shrink-0 bg-muted-foreground/40" />
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
{t("plugins.manager.status.disabled")}
|
{plugin.status === "server"
|
||||||
|
? t("plugins.manager.status.server")
|
||||||
|
: t("plugins.manager.status.disabled")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -806,11 +819,13 @@ function StatusCell({ plugin }: { plugin: PluginEntry }) {
|
|||||||
? t("plugins.manager.status.blocked")
|
? t("plugins.manager.status.blocked")
|
||||||
: failed
|
: failed
|
||||||
? t("plugins.manager.status.failed")
|
? t("plugins.manager.status.failed")
|
||||||
: !running
|
: plugin.status === "server"
|
||||||
? t("plugins.manager.status.disabled")
|
? t("plugins.manager.status.server")
|
||||||
: plugin.pinnedVersion
|
: !running
|
||||||
? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })
|
? t("plugins.manager.status.disabled")
|
||||||
: t("plugins.manager.status.running");
|
: plugin.pinnedVersion
|
||||||
|
? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })
|
||||||
|
: t("plugins.manager.status.running");
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex min-w-0 items-center gap-1.5 text-muted-foreground"
|
className="flex min-w-0 items-center gap-1.5 text-muted-foreground"
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
normalizeGitHubRepo,
|
normalizeGitHubRepo,
|
||||||
} from "@/lib/issue-url";
|
} from "@/lib/issue-url";
|
||||||
|
|
||||||
export type PluginStatus = "running" | "disabled" | "failed" | "blocked";
|
export type PluginStatus =
|
||||||
|
"running" | "disabled" | "failed" | "blocked" | "server";
|
||||||
|
|
||||||
/** One row of the Plugins tab: what is installed merged with the registry. */
|
/** One row of the Plugins tab: what is installed merged with the registry. */
|
||||||
export interface PluginEntry {
|
export interface PluginEntry {
|
||||||
@@ -100,6 +101,7 @@ export function pluginSource(summary: PluginSummary): PluginSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function pluginStatus(summary: PluginSummary): PluginStatus {
|
export function pluginStatus(summary: PluginSummary): PluginStatus {
|
||||||
|
if (summary.runsOnServer) return "server";
|
||||||
if (summary.state === "failed") return "failed";
|
if (summary.state === "failed") return "failed";
|
||||||
if (summary.state === "blocked") return "blocked";
|
if (summary.state === "blocked") return "blocked";
|
||||||
if (!summary.enabled) return "disabled";
|
if (!summary.enabled) return "disabled";
|
||||||
@@ -136,7 +138,10 @@ export function mergePlugins(
|
|||||||
latestBeta: entry?.latestBeta ?? null,
|
latestBeta: entry?.latestBeta ?? null,
|
||||||
channel: summary.channel ?? entry?.channel ?? "stable",
|
channel: summary.channel ?? entry?.channel ?? "stable",
|
||||||
isBeta: isPrerelease(summary.version),
|
isBeta: isPrerelease(summary.version),
|
||||||
updateAvailable: entry?.updateAvailable ?? false,
|
// The server keeps its own copy up to date.
|
||||||
|
updateAvailable: summary.runsOnServer
|
||||||
|
? false
|
||||||
|
: (entry?.updateAvailable ?? false),
|
||||||
addedCapabilities: entry?.addedCapabilities ?? [],
|
addedCapabilities: entry?.addedCapabilities ?? [],
|
||||||
versions: entry?.versions ?? [],
|
versions: entry?.versions ?? [],
|
||||||
capabilities: summary.capabilities ?? [],
|
capabilities: summary.capabilities ?? [],
|
||||||
|
|||||||
@@ -125,6 +125,14 @@ describe("mergePlugins", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers no update for a plugin a linked desktop leaves to its server", () => {
|
||||||
|
const [row] = mergePlugins(
|
||||||
|
[summary({ enabled: false, state: "disabled", runsOnServer: true })],
|
||||||
|
[entry()],
|
||||||
|
);
|
||||||
|
expect(row).toMatchObject({ status: "server", updateAvailable: false });
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps installed plugins when the registry is unreachable", () => {
|
it("keeps installed plugins when the registry is unreachable", () => {
|
||||||
const rows = mergePlugins([summary()], null);
|
const rows = mergePlugins([summary()], null);
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
@@ -140,6 +148,9 @@ describe("pluginStatus", () => {
|
|||||||
"disabled",
|
"disabled",
|
||||||
);
|
);
|
||||||
expect(pluginStatus(summary())).toBe("running");
|
expect(pluginStatus(summary())).toBe("running");
|
||||||
|
expect(pluginStatus(summary({ state: "failed", runsOnServer: true }))).toBe(
|
||||||
|
"server",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user