mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
feat: move workspaces into a first-party plugin
This commit is contained in:
1 parent
8742c5d381
commit
6f0dff58a3
19 files changed
+275
-32
No files matched your search
@@ -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 express, { type Request, type Response } from "express";
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
import { databaseLogger } from "../../../src/backend/utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||||
import { createCurrentWorkspaceRepository } from "../repositories/factory.js";
|
import { createCurrentWorkspaceRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||||
import type { WorkspaceRecord } from "../repositories/workspace-repository.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 authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
@@ -635,4 +639,10 @@ router.delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default router;
|
export function startWorkspacesService(): void {
|
||||||
|
registerWorkspacesRouter(router);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopWorkspacesService(): void {
|
||||||
|
unregisterWorkspacesRouter();
|
||||||
|
}
|
||||||
+2
-2
@@ -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 { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
duplicateWorkspace,
|
duplicateWorkspace,
|
||||||
setDefaultWorkspace,
|
setDefaultWorkspace,
|
||||||
unsetDefaultWorkspace,
|
unsetDefaultWorkspace,
|
||||||
} from "@/api/workspaces-api";
|
} from "./workspaces-api";
|
||||||
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
|
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
|
||||||
|
|
||||||
function timeAgo(isoStr: string): string {
|
function timeAgo(isoStr: string): string {
|
||||||
@@ -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.
@@ -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": []
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import alertRoutes from "./routes/alerts.js";
|
|||||||
import credentialsRoutes from "./routes/credentials.js";
|
import credentialsRoutes from "./routes/credentials.js";
|
||||||
import snippetsRoutes from "./routes/snippets.js";
|
import snippetsRoutes from "./routes/snippets.js";
|
||||||
import fleetsDispatch from "./routes/fleet-dispatch.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 c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||||
import terminalRoutes from "./routes/terminal.js";
|
import terminalRoutes from "./routes/terminal.js";
|
||||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||||
@@ -1754,7 +1754,7 @@ app.use("/alerts", alertRoutes);
|
|||||||
app.use("/credentials", credentialsRoutes);
|
app.use("/credentials", credentialsRoutes);
|
||||||
app.use("/snippets", snippetsRoutes);
|
app.use("/snippets", snippetsRoutes);
|
||||||
app.use("/fleets", fleetsDispatch);
|
app.use("/fleets", fleetsDispatch);
|
||||||
app.use("/workspaces", workspaceRoutes);
|
app.use("/workspaces", workspaceDispatch);
|
||||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||||
app.use("/terminal", terminalRoutes);
|
app.use("/terminal", terminalRoutes);
|
||||||
app.use("/session_logs", sessionLogRoutes);
|
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);
|
||||||
|
}
|
||||||
@@ -98,6 +98,14 @@
|
|||||||
* The worker ctx has no fetch primitive at all (ctx.fetch is notImplemented
|
* 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
|
* in SDK v1, see worker-bootstrap.ts), so there is currently no way for a
|
||||||
* worker-tier plugin to make an outbound HTTP request.
|
* 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([
|
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||||
"ssh-terminal",
|
"ssh-terminal",
|
||||||
@@ -110,6 +118,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
|||||||
"automations",
|
"automations",
|
||||||
"network-topology",
|
"network-topology",
|
||||||
"tailscale",
|
"tailscale",
|
||||||
|
"workspaces",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
||||||
|
|||||||
+10
-10
@@ -295,16 +295,16 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
|||||||
// ssh-terminal, docker and host-metrics plugins start their own servers,
|
// ssh-terminal, docker and host-metrics plugins start their own servers,
|
||||||
// so disabling any of them stops its WS/HTTP server. See
|
// so disabling any of them stops its WS/HTTP server. See
|
||||||
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
||||||
// AI, Proxmox, Remote Desktop, Fleets, Automations and Network Topology
|
// AI, Proxmox, Remote Desktop, Fleets, Automations, Network Topology and
|
||||||
// are also absent for the same reason: each plugin registers its router
|
// Workspaces are also absent for the same reason: each plugin registers
|
||||||
// with its own dispatcher in database.ts on activate (/ai, /proxmox,
|
// its router with its own dispatcher in database.ts on activate (/ai,
|
||||||
// /guacamole, /fleets, /automations, /network-topology), so disabling
|
// /proxmox, /guacamole, /fleets, /automations, /network-topology,
|
||||||
// any of them makes its routes 404 instead of leaving a dead import
|
// /workspaces), so disabling any of them makes its routes 404 instead of
|
||||||
// here. Automations' scheduler (due schedules, dwell rechecks,
|
// leaving a dead import here. Automations' scheduler (due schedules,
|
||||||
// docker-event polling, history pruning) also starts from its own
|
// dwell rechecks, docker-event polling, history pruning) also starts
|
||||||
// activate() rather than here. See plugins/ai, plugins/proxmox,
|
// from its own activate() rather than here. See plugins/ai,
|
||||||
// plugins/remote-desktop, plugins/fleets, plugins/automations and
|
// plugins/proxmox, plugins/remote-desktop, plugins/fleets,
|
||||||
// plugins/network-topology.
|
// plugins/automations, plugins/network-topology and plugins/workspaces.
|
||||||
await import("./hosts/tunnel/index.js");
|
await import("./hosts/tunnel/index.js");
|
||||||
await import("./hosts/file-manager/index.js");
|
await import("./hosts/file-manager/index.js");
|
||||||
await import("./hosts/tmux/index.js");
|
await import("./hosts/tmux/index.js");
|
||||||
|
|||||||
@@ -374,3 +374,44 @@ describe("bundled network-topology plugin", () => {
|
|||||||
expect(source).toMatch(/export async function deactivate/);
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,7 +39,7 @@ function manifest(overrides: Record<string, unknown> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("first-party allowlist", () => {
|
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([
|
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
||||||
"ai",
|
"ai",
|
||||||
"automations",
|
"automations",
|
||||||
@@ -51,10 +51,11 @@ describe("first-party allowlist", () => {
|
|||||||
"remote-desktop",
|
"remote-desktop",
|
||||||
"ssh-terminal",
|
"ssh-terminal",
|
||||||
"tailscale",
|
"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("ssh-terminal")).toBe(true);
|
||||||
expect(isFirstParty("docker")).toBe(true);
|
expect(isFirstParty("docker")).toBe(true);
|
||||||
expect(isFirstParty("host-metrics")).toBe(true);
|
expect(isFirstParty("host-metrics")).toBe(true);
|
||||||
@@ -65,6 +66,7 @@ describe("first-party allowlist", () => {
|
|||||||
expect(isFirstParty("automations")).toBe(true);
|
expect(isFirstParty("automations")).toBe(true);
|
||||||
expect(isFirstParty("network-topology")).toBe(true);
|
expect(isFirstParty("network-topology")).toBe(true);
|
||||||
expect(isFirstParty("tailscale")).toBe(true);
|
expect(isFirstParty("tailscale")).toBe(true);
|
||||||
|
expect(isFirstParty("workspaces")).toBe(true);
|
||||||
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
||||||
expect(isFirstParty("community-plugin")).toBe(false);
|
expect(isFirstParty("community-plugin")).toBe(false);
|
||||||
expect(isFirstParty("")).toBe(false);
|
expect(isFirstParty("")).toBe(false);
|
||||||
@@ -93,6 +95,9 @@ describe("runsInProcess", () => {
|
|||||||
runsInProcess("network-topology", [TRANSPORT_OWNER_CAPABILITY]),
|
runsInProcess("network-topology", [TRANSPORT_OWNER_CAPABILITY]),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(runsInProcess("tailscale", [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
|
// On the list but not asking for it: stays in a worker, so the manifest
|
||||||
// remains an honest description of what the plugin does.
|
// remains an honest description of what the plugin does.
|
||||||
@@ -106,6 +111,7 @@ describe("runsInProcess", () => {
|
|||||||
expect(runsInProcess("automations", ["hosts.read"])).toBe(false);
|
expect(runsInProcess("automations", ["hosts.read"])).toBe(false);
|
||||||
expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false);
|
expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false);
|
||||||
expect(runsInProcess("tailscale", ["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.
|
// Asking for it but not on the list.
|
||||||
expect(
|
expect(
|
||||||
@@ -233,6 +239,18 @@ describe("manifest gate on the reserved capability", () => {
|
|||||||
expect(parsed?.id).toBe("tailscale");
|
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", () => {
|
it("still accepts an ordinary manifest that never mentions it", () => {
|
||||||
const { manifest: parsed, errors } = parseManifest(
|
const { manifest: parsed, errors } = parseManifest(
|
||||||
manifest({ permissions: ["hosts.read"] }),
|
manifest({ permissions: ["hosts.read"] }),
|
||||||
|
|||||||
+7
-2
@@ -161,8 +161,13 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { default: router } =
|
vi.mock("../../../database/routes/workspace-dispatch.js", () => ({
|
||||||
await import("../../../database/routes/workspaces.js");
|
registerWorkspacesRouter: vi.fn(),
|
||||||
|
unregisterWorkspacesRouter: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { router } =
|
||||||
|
await import("../../../../../plugins/workspaces/backend/routes.js");
|
||||||
|
|
||||||
function findLayer(method: string, path: string) {
|
function findLayer(method: string, path: string) {
|
||||||
const stack = (router as unknown as Router).stack as Array<{
|
const stack = (router as unknown as Router).stack as Array<{
|
||||||
+3
-3
@@ -102,7 +102,7 @@ const TailscaleDevicesPanel = lazy(() =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const WorkspacesPanel = lazy(() =>
|
const WorkspacesPanel = lazy(() =>
|
||||||
import("@/sidebar/WorkspacesPanel").then((m) => ({
|
import("../../plugins/workspaces/frontend/WorkspacesPanel").then((m) => ({
|
||||||
default: m.WorkspacesPanel,
|
default: m.WorkspacesPanel,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -197,12 +197,12 @@ import {
|
|||||||
listWorkspaces,
|
listWorkspaces,
|
||||||
applyWorkspaceServer,
|
applyWorkspaceServer,
|
||||||
saveLastSessionWorkspace,
|
saveLastSessionWorkspace,
|
||||||
} from "@/api/workspaces-api";
|
} from "../../plugins/workspaces/frontend/workspaces-api";
|
||||||
import {
|
import {
|
||||||
buildWorkspacePayload as buildWorkspacePayloadUtil,
|
buildWorkspacePayload as buildWorkspacePayloadUtil,
|
||||||
remapSlotIds,
|
remapSlotIds,
|
||||||
resolveWorkspaceTabTarget,
|
resolveWorkspaceTabTarget,
|
||||||
} from "@/shell/workspaceUtils";
|
} from "../../plugins/workspaces/frontend/workspaceUtils";
|
||||||
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
|
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
|
||||||
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
|
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
|
||||||
import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx";
|
import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx";
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
|||||||
buildWorkspaceTabSnapshots,
|
buildWorkspaceTabSnapshots,
|
||||||
remapSlotIds,
|
remapSlotIds,
|
||||||
resolveWorkspaceTabTarget,
|
resolveWorkspaceTabTarget,
|
||||||
} from "../../shell/workspaceUtils";
|
} from "../../../../../plugins/workspaces/frontend/workspaceUtils";
|
||||||
import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types";
|
import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types";
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
+1
-1
@@ -24,7 +24,7 @@ import {
|
|||||||
applyWorkspaceServer,
|
applyWorkspaceServer,
|
||||||
getLastSessionWorkspace,
|
getLastSessionWorkspace,
|
||||||
saveLastSessionWorkspace,
|
saveLastSessionWorkspace,
|
||||||
} from "../../api/workspaces-api";
|
} from "../../../../../plugins/workspaces/frontend/workspaces-api";
|
||||||
import type { WorkspacePayload } from "@/types/ui-types";
|
import type { WorkspacePayload } from "@/types/ui-types";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
railItemLabel,
|
railItemLabel,
|
||||||
visibleRailItems,
|
visibleRailItems,
|
||||||
} from "@/sidebar/rail-items";
|
} 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 type { TabType } from "@/types/ui-types";
|
||||||
import en from "@/locales/en.json";
|
import en from "@/locales/en.json";
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
"plugins/remote-desktop/backend/**/*.ts",
|
"plugins/remote-desktop/backend/**/*.ts",
|
||||||
"plugins/fleets/backend/**/*.ts",
|
"plugins/fleets/backend/**/*.ts",
|
||||||
"plugins/automations/backend/**/*.ts",
|
"plugins/automations/backend/**/*.ts",
|
||||||
"plugins/network-topology/backend/**/*.ts"
|
"plugins/network-topology/backend/**/*.ts",
|
||||||
|
"plugins/workspaces/backend/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["src/**/*.test.ts"]
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user