feat: move workspaces into a first-party plugin

This commit is contained in:
LukeGus committed 2026-09-22 10:45:15 -05:00
1 parent 8742c5d381
commit 6f0dff58a3
19 files changed
+275 -32

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
/**
* Workspaces - first-party, in-process plugin.
*
* Unlike the other first-party plugins (see src/backend/plugins/first-party.ts),
* this one does not own a transport or touch the shared SSH pool -- its routes
* are plain per-user JSON CRUD against a single repository. It runs in-process
* anyway to keep its existing /workspaces URL and its existing dedicated table
* (user_workspaces, keyed by userId) rather than moving onto
* /plugin-api/workspaces/* and the generic key-value ctx.storage the worker
* tier would require, matching how network-topology was migrated in place
* rather than reworked around the worker ctx boundary.
*
* Like network-topology, this plugin does not get its own port -- it
* registers its Express router with the always-mounted /workspaces
* dispatcher in src/backend/database/routes/workspace-dispatch.ts.
*
* routes.ts is what used to be src/backend/database/routes/workspaces.ts,
* moved here and rewritten from `export default router` into
* startWorkspacesService()/stopWorkspacesService(), called from
* activate()/deactivate() below. The user_workspaces table and its
* repository stay in core (src/backend/database/db/schema.ts,
* src/backend/database/repositories/workspace-repository.ts) -- this
* plugin system has no owned-table migration mechanism of its own, and the
* table carries an FK into core's users table.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Loads a module by its path relative to this file's own directory.
*
* The plugin ships beside dist/backend, so in a built server the compiled
* .js for routes.js sits right next to this .mjs. Under vitest/dev nothing
* imports this plugin directly (the loader loads in-process plugins with a
* plain dynamic import, no tsx loader involved), so the only layout that
* needs to resolve here is the built one.
*/
async function loadRelative(relativePath) {
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.join(here, `${relativePath}.js`);
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
}
let routesModule = null;
export async function activate(ctx) {
routesModule = await loadRelative("routes");
routesModule.startWorkspacesService();
ctx.log.info("Workspaces router registered at /workspaces");
}
export async function deactivate() {
if (routesModule) {
routesModule.stopWorkspacesService();
routesModule = null;
}
}
@@ -1,11 +1,15 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { AuthenticatedRequest } from "../../../src/types/index.js";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { createCurrentWorkspaceRepository } from "../repositories/factory.js";
import type { WorkspaceRecord } from "../repositories/workspace-repository.js";
import { databaseLogger } from "../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { createCurrentWorkspaceRepository } from "../../../src/backend/database/repositories/factory.js";
import type { WorkspaceRecord } from "../../../src/backend/database/repositories/workspace-repository.js";
import {
registerWorkspacesRouter,
unregisterWorkspacesRouter,
} from "../../../src/backend/database/routes/workspace-dispatch.js";
const router = express.Router();
export const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
@@ -635,4 +639,10 @@ router.delete(
},
);
export default router;
export function startWorkspacesService(): void {
registerWorkspacesRouter(router);
}
export function stopWorkspacesService(): void {
unregisterWorkspacesRouter();
}
@@ -1,4 +1,4 @@
import { getErrorMessage } from "../lib/error-message.js";
import { getErrorMessage } from "@/lib/error-message.js";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -33,7 +33,7 @@ import {
duplicateWorkspace,
setDefaultWorkspace,
unsetDefaultWorkspace,
} from "@/api/workspaces-api";
} from "./workspaces-api";
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
function timeAgo(isoStr: string): string {
+34
View File
@@ -0,0 +1,34 @@
/**
* Frontend half of the workspaces plugin.
*
* WorkspacesPanel is a left-sidebar railView panel (opened from the rail,
* not a Tab), the same shape as FleetsPanel/HostsPanel/CredentialsPanel.
* None of those go through registerRailItem/registerTabComponent either --
* per src/ui/shell/pluginLoader.ts's own comment, a rail-view panel like
* this is rendered directly by AppShell.tsx rather than registered at
* runtime. WorkspacesPanel is reached today through a direct relative
* import in AppShell.tsx (../../plugins/workspaces/frontend/WorkspacesPanel),
* not through register() below.
*
* register()/unregister() still follow the same shape every other plugin's
* frontend entry does, so this is ready the moment a real frontend plugin
* loader exists -- see plugins/ssh-terminal/frontend/index.mjs and
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
* "there is no plugin loader yet".
*/
export const id = "workspaces";
export const railViewId = "workspaces";
export async function register({ registerRailItem, icons }) {
registerRailItem?.({
id: railViewId,
icon: icons.LayoutTemplate,
labelKey: "nav.workspaces",
separatorAfter: true,
});
}
export async function unregister({ unregisterRailItem }) {
unregisterRailItem?.(railViewId);
}
File renamed without changes.
File renamed without changes.
+20
View File
@@ -0,0 +1,20 @@
{
"id": "workspaces",
"name": "Workspaces",
"version": "1.0.0",
"description": "Save and restore named tab/split layouts per user.",
"author": { "name": "Termix" },
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Productivity",
"icon": "LayoutTemplate",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["process:transport-owner"],
"sidecars": []
}
+2 -2
View File
@@ -11,7 +11,7 @@ import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import fleetsDispatch from "./routes/fleet-dispatch.js";
import workspaceRoutes from "./routes/workspaces.js";
import workspaceDispatch from "./routes/workspace-dispatch.js";
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
@@ -1754,7 +1754,7 @@ app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/fleets", fleetsDispatch);
app.use("/workspaces", workspaceRoutes);
app.use("/workspaces", workspaceDispatch);
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
@@ -0,0 +1,47 @@
// Mounted at /workspaces. Workspaces ships as a first-party in-process
// plugin (plugins/workspaces) rather than a route file imported directly
// here, so this module exists to keep /workspaces/* working at its original
// path without handing the plugin a reference to the shared app instance.
// The plugin runtime registers its router via registerWorkspacesRouter when
// it activates, and clears it on deactivate, so a disabled plugin falls back
// to the 404 below.
//
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
// to exactly one first-party router (the Workspaces plugin's own), and every
// route in that router already applies authenticateJWT itself, the same as
// every other router mounted alongside it here (/users, /host, /snippets,
// ...). /plugin-api gates at the mount point because it forwards to arbitrary
// third-party plugin code that cannot be assumed to gate itself; that
// reasoning does not apply to a single first-party router that already does.
import type { Request, Response, NextFunction, Router } from "express";
import { databaseLogger } from "../../utils/logger.js";
let activeRouter: Router | null = null;
export function registerWorkspacesRouter(router: Router): void {
activeRouter = router;
databaseLogger.info("Registered Workspaces plugin router", {
operation: "workspaces_dispatch_register",
});
}
export function unregisterWorkspacesRouter(): void {
if (!activeRouter) return;
activeRouter = null;
databaseLogger.info("Unregistered Workspaces plugin router", {
operation: "workspaces_dispatch_register",
});
}
export default function workspaceDispatch(
req: Request,
res: Response,
next: NextFunction,
): void {
if (!activeRouter) {
res.status(404).json({ error: "Workspaces is not available" });
return;
}
activeRouter(req, res, next);
}
+9
View File
@@ -98,6 +98,14 @@
* The worker ctx has no fetch primitive at all (ctx.fetch is notImplemented
* in SDK v1, see worker-bootstrap.ts), so there is currently no way for a
* worker-tier plugin to make an outbound HTTP request.
*
* workspaces is here for the same reason network-topology is: its routes are
* plain per-user JSON CRUD against a single repository, and would fit the
* worker ctx fine on their own. It stays in-process to keep its existing
* /workspaces URL and its existing dedicated table (user_workspaces, keyed
* by userId) rather than moving onto /plugin-api/workspaces/* and the
* generic key-value ctx.storage the worker tier would require -- a URL and
* storage-model change, not a capability gap.
*/
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
"ssh-terminal",
@@ -110,6 +118,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
"automations",
"network-topology",
"tailscale",
"workspaces",
]);
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
+10 -10
View File
@@ -295,16 +295,16 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// ssh-terminal, docker and host-metrics plugins start their own servers,
// so disabling any of them stops its WS/HTTP server. See
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
// AI, Proxmox, Remote Desktop, Fleets, Automations and Network Topology
// are also absent for the same reason: each plugin registers its router
// with its own dispatcher in database.ts on activate (/ai, /proxmox,
// /guacamole, /fleets, /automations, /network-topology), so disabling
// any of them makes its routes 404 instead of leaving a dead import
// here. Automations' scheduler (due schedules, dwell rechecks,
// docker-event polling, history pruning) also starts from its own
// activate() rather than here. See plugins/ai, plugins/proxmox,
// plugins/remote-desktop, plugins/fleets, plugins/automations and
// plugins/network-topology.
// AI, Proxmox, Remote Desktop, Fleets, Automations, Network Topology and
// Workspaces are also absent for the same reason: each plugin registers
// its router with its own dispatcher in database.ts on activate (/ai,
// /proxmox, /guacamole, /fleets, /automations, /network-topology,
// /workspaces), so disabling any of them makes its routes 404 instead of
// leaving a dead import here. Automations' scheduler (due schedules,
// dwell rechecks, docker-event polling, history pruning) also starts
// from its own activate() rather than here. See plugins/ai,
// plugins/proxmox, plugins/remote-desktop, plugins/fleets,
// plugins/automations, plugins/network-topology and plugins/workspaces.
await import("./hosts/tunnel/index.js");
await import("./hosts/file-manager/index.js");
await import("./hosts/tmux/index.js");
@@ -374,3 +374,44 @@ describe("bundled network-topology plugin", () => {
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled workspaces plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "workspaces");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("workspaces"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("workspaces");
expect(manifest?.category).toBe("Productivity");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("workspaces"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("workspaces")).toBe(true);
expect(runsInProcess("workspaces", manifest!.permissions)).toBe(true);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"workspaces",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
+20 -2
View File
@@ -39,7 +39,7 @@ function manifest(overrides: Record<string, unknown> = {}) {
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology and tailscale", () => {
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"ai",
"automations",
@@ -51,10 +51,11 @@ describe("first-party allowlist", () => {
"remote-desktop",
"ssh-terminal",
"tailscale",
"workspaces",
]);
});
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology and tailscale and nothing else", () => {
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true);
@@ -65,6 +66,7 @@ describe("first-party allowlist", () => {
expect(isFirstParty("automations")).toBe(true);
expect(isFirstParty("network-topology")).toBe(true);
expect(isFirstParty("tailscale")).toBe(true);
expect(isFirstParty("workspaces")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
@@ -93,6 +95,9 @@ describe("runsInProcess", () => {
runsInProcess("network-topology", [TRANSPORT_OWNER_CAPABILITY]),
).toBe(true);
expect(runsInProcess("tailscale", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
// On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does.
@@ -106,6 +111,7 @@ describe("runsInProcess", () => {
expect(runsInProcess("automations", ["hosts.read"])).toBe(false);
expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false);
expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false);
expect(runsInProcess("workspaces", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(
@@ -233,6 +239,18 @@ describe("manifest gate on the reserved capability", () => {
expect(parsed?.id).toBe("tailscale");
});
it("allows it for workspaces", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "workspaces",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("workspaces");
});
it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }),
@@ -161,8 +161,13 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
const { default: router } =
await import("../../../database/routes/workspaces.js");
vi.mock("../../../database/routes/workspace-dispatch.js", () => ({
registerWorkspacesRouter: vi.fn(),
unregisterWorkspacesRouter: vi.fn(),
}));
const { router } =
await import("../../../../../plugins/workspaces/backend/routes.js");
function findLayer(method: string, path: string) {
const stack = (router as unknown as Router).stack as Array<{
+3 -3
View File
@@ -102,7 +102,7 @@ const TailscaleDevicesPanel = lazy(() =>
),
);
const WorkspacesPanel = lazy(() =>
import("@/sidebar/WorkspacesPanel").then((m) => ({
import("../../plugins/workspaces/frontend/WorkspacesPanel").then((m) => ({
default: m.WorkspacesPanel,
})),
);
@@ -197,12 +197,12 @@ import {
listWorkspaces,
applyWorkspaceServer,
saveLastSessionWorkspace,
} from "@/api/workspaces-api";
} from "../../plugins/workspaces/frontend/workspaces-api";
import {
buildWorkspacePayload as buildWorkspacePayloadUtil,
remapSlotIds,
resolveWorkspaceTabTarget,
} from "@/shell/workspaceUtils";
} from "../../plugins/workspaces/frontend/workspaceUtils";
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx";
@@ -4,7 +4,7 @@ import {
buildWorkspaceTabSnapshots,
remapSlotIds,
resolveWorkspaceTabTarget,
} from "../../shell/workspaceUtils";
} from "../../../../../plugins/workspaces/frontend/workspaceUtils";
import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types";
afterEach(() => vi.unstubAllGlobals());
@@ -24,7 +24,7 @@ import {
applyWorkspaceServer,
getLastSessionWorkspace,
saveLastSessionWorkspace,
} from "../../api/workspaces-api";
} from "../../../../../plugins/workspaces/frontend/workspaces-api";
import type { WorkspacePayload } from "@/types/ui-types";
beforeEach(() => {
+1 -1
View File
@@ -8,7 +8,7 @@ import {
railItemLabel,
visibleRailItems,
} from "@/sidebar/rail-items";
import { WORKSPACE_CAPTURABLE_TYPES } from "@/shell/workspaceUtils";
import { WORKSPACE_CAPTURABLE_TYPES } from "../../../../plugins/workspaces/frontend/workspaceUtils";
import type { TabType } from "@/types/ui-types";
import en from "@/locales/en.json";
+2 -1
View File
@@ -34,7 +34,8 @@
"plugins/remote-desktop/backend/**/*.ts",
"plugins/fleets/backend/**/*.ts",
"plugins/automations/backend/**/*.ts",
"plugins/network-topology/backend/**/*.ts"
"plugins/network-topology/backend/**/*.ts",
"plugins/workspaces/backend/**/*.ts"
],
"exclude": ["src/**/*.test.ts"]
}