mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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
|
||||
- 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
|
||||
|
||||
|
||||
@@ -17,7 +17,11 @@ import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.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 {
|
||||
pluginDocsUrl,
|
||||
@@ -236,6 +240,9 @@ export async function listPreLoginPlugins(): Promise<
|
||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||
* `dependencies` and `optionalDependencies` let the loader activate
|
||||
* 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:
|
||||
* - Plugins
|
||||
* responses:
|
||||
@@ -253,6 +260,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
userId,
|
||||
"admin.plugins.manage",
|
||||
);
|
||||
const linked = await isLinkedDesktop();
|
||||
|
||||
// One query for every plugin rather than one per plugin.
|
||||
const grantsByPlugin = new Map<string, string[]>();
|
||||
@@ -285,6 +293,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
let features: string[] = [];
|
||||
let docs: string | undefined;
|
||||
let env: PluginEnvVar[] = [];
|
||||
let desktopMode: unknown = loaded?.manifest.desktop;
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
@@ -299,7 +308,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
env?: unknown;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
desktop?: unknown;
|
||||
};
|
||||
desktopMode = loaded?.manifest.desktop ?? manifest?.desktop;
|
||||
contributes = manifest?.contributes ?? null;
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
@@ -332,6 +343,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
version: record.version,
|
||||
enabled: record.state === "enabled",
|
||||
state: loaded?.state ?? record.state,
|
||||
runsOnServer: linked && desktopMode === "server",
|
||||
contributes,
|
||||
icon,
|
||||
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 { resolveSecretRequirements } from "./secret-registry.js";
|
||||
import { recordConflict } from "./conflicts.js";
|
||||
import { installHostModuleFallback } from "./host-modules.js";
|
||||
import { tablePrefix } from "@termix-ssh/plugin-sdk/db";
|
||||
|
||||
export type PluginState =
|
||||
@@ -567,6 +568,7 @@ export class PluginLoader {
|
||||
)}`;
|
||||
|
||||
try {
|
||||
installHostModuleFallback();
|
||||
const imported = (await import(entry)) as {
|
||||
activate?: PluginModule["activate"];
|
||||
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 () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
|
||||
@@ -577,6 +599,7 @@ describe("plugins route", () => {
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "enabled",
|
||||
runsOnServer: false,
|
||||
contributes: null,
|
||||
dependencies: {},
|
||||
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 | disabled | blocked | failed, or the loader's live state. */
|
||||
state: string;
|
||||
/** A linked desktop leaves this plugin to its server. */
|
||||
runsOnServer?: boolean;
|
||||
contributes: PluginContributions | null;
|
||||
/** Lucide icon name from the manifest. */
|
||||
icon?: string;
|
||||
|
||||
@@ -1875,12 +1875,15 @@
|
||||
"running": "Running",
|
||||
"disabled": "Disabled",
|
||||
"failed": "Stopped unexpectedly",
|
||||
"blocked": "Waiting on another plugin"
|
||||
"blocked": "Waiting on another plugin",
|
||||
"server": "Runs on your server"
|
||||
},
|
||||
"groups": {
|
||||
"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": {
|
||||
"popular": "Most popular",
|
||||
"name": "Name",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Lightbulb,
|
||||
Pin,
|
||||
RotateCcw,
|
||||
Server,
|
||||
Settings,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
@@ -225,6 +226,15 @@ export function PluginDetail({
|
||||
</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="flex flex-col gap-2 lg:col-span-2">
|
||||
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
|
||||
|
||||
@@ -483,7 +483,10 @@ function InstalledSection({
|
||||
sort,
|
||||
);
|
||||
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 listColumns: DataColumn<PluginEntry>[] = [
|
||||
@@ -587,6 +590,14 @@ function InstalledSection({
|
||||
{collection(running)}
|
||||
</Group>
|
||||
)}
|
||||
{onServer.length > 0 && (
|
||||
<Group
|
||||
title={t("plugins.manager.groups.onServer")}
|
||||
count={onServer.length}
|
||||
>
|
||||
{collection(onServer)}
|
||||
</Group>
|
||||
)}
|
||||
{stopped.length > 0 && (
|
||||
<Group
|
||||
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">
|
||||
<span className="size-1.5 shrink-0 bg-muted-foreground/40" />
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
@@ -806,11 +819,13 @@ function StatusCell({ plugin }: { plugin: PluginEntry }) {
|
||||
? t("plugins.manager.status.blocked")
|
||||
: failed
|
||||
? t("plugins.manager.status.failed")
|
||||
: !running
|
||||
? t("plugins.manager.status.disabled")
|
||||
: plugin.pinnedVersion
|
||||
? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })
|
||||
: t("plugins.manager.status.running");
|
||||
: plugin.status === "server"
|
||||
? t("plugins.manager.status.server")
|
||||
: !running
|
||||
? t("plugins.manager.status.disabled")
|
||||
: plugin.pinnedVersion
|
||||
? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })
|
||||
: t("plugins.manager.status.running");
|
||||
return (
|
||||
<span
|
||||
className="flex min-w-0 items-center gap-1.5 text-muted-foreground"
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
normalizeGitHubRepo,
|
||||
} 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. */
|
||||
export interface PluginEntry {
|
||||
@@ -100,6 +101,7 @@ export function pluginSource(summary: PluginSummary): PluginSource {
|
||||
}
|
||||
|
||||
export function pluginStatus(summary: PluginSummary): PluginStatus {
|
||||
if (summary.runsOnServer) return "server";
|
||||
if (summary.state === "failed") return "failed";
|
||||
if (summary.state === "blocked") return "blocked";
|
||||
if (!summary.enabled) return "disabled";
|
||||
@@ -136,7 +138,10 @@ export function mergePlugins(
|
||||
latestBeta: entry?.latestBeta ?? null,
|
||||
channel: summary.channel ?? entry?.channel ?? "stable",
|
||||
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 ?? [],
|
||||
versions: entry?.versions ?? [],
|
||||
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", () => {
|
||||
const rows = mergePlugins([summary()], null);
|
||||
expect(rows).toHaveLength(1);
|
||||
@@ -140,6 +148,9 @@ describe("pluginStatus", () => {
|
||||
"disabled",
|
||||
);
|
||||
expect(pluginStatus(summary())).toBe("running");
|
||||
expect(pluginStatus(summary({ state: "failed", runsOnServer: true }))).toBe(
|
||||
"server",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user