diff --git a/CHANGELOG.md b/CHANGELOG.md index 432789c8c..a7f14d60f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 7eae04585..5dffe395a 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -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(); @@ -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; optionalDependencies?: Record; + 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, diff --git a/src/backend/plugins/host-modules.ts b/src/backend/plugins/host-modules.ts new file mode 100644 index 000000000..6aa3f01c9 --- /dev/null +++ b/src/backend/plugins/host-modules.ts @@ -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, + }); + } + }, + }); +} diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index 2b6961a71..8521a8e61 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -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"]; diff --git a/src/backend/tests/database/routes/plugins.test.ts b/src/backend/tests/database/routes/plugins.test.ts index ba0677911..1d0b5a002 100644 --- a/src/backend/tests/database/routes/plugins.test.ts +++ b/src/backend/tests/database/routes/plugins.test.ts @@ -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: {}, diff --git a/src/backend/tests/plugins/host-modules.test.ts b/src/backend/tests/plugins/host-modules.test.ts new file mode 100644 index 000000000..9620502f7 --- /dev/null +++ b/src/backend/tests/plugins/host-modules.test.ts @@ -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'"); + }); +}); diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 555c3c1a5..308beac7d 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -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; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index ff70dc016..4edbe7e09 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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", diff --git a/src/ui/plugins/PluginDetail.tsx b/src/ui/plugins/PluginDetail.tsx index 294094c72..5aaaf6d12 100644 --- a/src/ui/plugins/PluginDetail.tsx +++ b/src/ui/plugins/PluginDetail.tsx @@ -11,6 +11,7 @@ import { Lightbulb, Pin, RotateCcw, + Server, Settings, Trash2, TriangleAlert, @@ -225,6 +226,15 @@ export function PluginDetail({ )} + {plugin.status === "server" && ( +
+ + + {t("plugins.manager.runsOnServerHint")} + +
+ )} +
diff --git a/src/ui/plugins/PluginsTab.tsx b/src/ui/plugins/PluginsTab.tsx index 7f841dde0..d4a8e1d0d 100644 --- a/src/ui/plugins/PluginsTab.tsx +++ b/src/ui/plugins/PluginsTab.tsx @@ -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[] = [ @@ -587,6 +590,14 @@ function InstalledSection({ {collection(running)} )} + {onServer.length > 0 && ( + + {collection(onServer)} + + )} {stopped.length > 0 && ( - {t("plugins.manager.status.disabled")} + {plugin.status === "server" + ? t("plugins.manager.status.server") + : t("plugins.manager.status.disabled")}
) : ( @@ -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 ( { ); }); + 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", + ); }); });