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

This commit is contained in:
LukeGus committed 2026-10-11 12:07:22 -05:00
1 parent e92db4e9f8
commit 91e1ee94fa
12 files changed
+261 -12

No files matched your search

+2
View File
@@ -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
+13 -1
View File
@@ -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,
+65
View File
@@ -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,
});
}
},
});
}
+2
View File
@@ -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'");
});
});
+2
View File
@@ -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;
+5 -2
View File
@@ -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",
+10
View File
@@ -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} />
+22 -7
View File
@@ -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"
+7 -2
View File
@@ -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 ?? [],
+11
View File
@@ -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",
);
});
});