From a2479c19b062f9b2e4c463e2345bdac208e629f8 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 21 Sep 2026 09:55:37 -0500 Subject: [PATCH] feat: ship docker as a first-party in-process plugin Moves docker backend and frontend into plugins/docker/, alongside ssh-terminal. Adds a host editor tab seam, a dashboard card registry, and a build step to compile plugin backends into dist. --- .gitignore | 8 + .../docker/backend}/console.ts | 42 ++++-- .../docker/backend}/container-routes.ts | 7 +- .../docker/backend}/container-runtime.ts | 0 plugins/docker/backend/index.mjs | 142 ++++++++++++++++++ .../docker/backend}/routes.ts | 50 +++--- .../docker/backend}/session-manager.ts | 5 +- .../docker/frontend}/DockerApp.tsx | 2 +- .../docker/frontend}/DockerManager.tsx | 0 .../frontend}/components/ConsoleTerminal.tsx | 2 +- .../frontend}/components/ContainerCard.tsx | 2 +- .../frontend}/components/ContainerDetail.tsx | 0 .../frontend}/components/ContainerList.tsx | 0 .../frontend}/components/ContainerStats.tsx | 2 +- .../frontend}/components/ContainerTable.tsx | 2 +- .../docker/frontend}/components/LogViewer.tsx | 2 +- plugins/docker/frontend/index.mjs | 68 +++++++++ plugins/docker/manifest.json | 35 +++++ plugins/package.json | 3 + scripts/copy-bundled-plugins.cjs | 103 ++++++++++++- src/backend/hosts/docker/index.ts | 79 ---------- src/backend/plugins/first-party.ts | 7 + src/backend/starter.ts | 7 +- .../tests/plugins/bundled-plugin.test.ts | 54 +++++++ src/backend/tests/plugins/first-party.test.ts | 24 ++- src/main.tsx | 4 +- src/ui/dashboard/dashboard-cards-registry.ts | 33 ++++ .../homepage/widgets/DockerWidget.tsx | 2 +- src/ui/shell/tabUtils.tsx | 2 +- src/ui/sidebar/HostEditor.tsx | 8 + src/ui/sidebar/HostManagerTabs.tsx | 51 ++++++- .../dashboard-cards-registry.test.ts | 36 +++++ .../sidebar/host-editor-tab-seam.test.tsx | 59 ++++++++ tsconfig.plugins.json | 31 ++++ 34 files changed, 729 insertions(+), 143 deletions(-) rename {src/backend/hosts/docker => plugins/docker/backend}/console.ts (94%) rename {src/backend/hosts/docker => plugins/docker/backend}/container-routes.ts (98%) rename {src/backend/hosts/docker => plugins/docker/backend}/container-runtime.ts (100%) create mode 100644 plugins/docker/backend/index.mjs rename {src/backend/hosts/docker => plugins/docker/backend}/routes.ts (96%) rename {src/backend/hosts/docker => plugins/docker/backend}/session-manager.ts (93%) rename {src/ui/features/docker => plugins/docker/frontend}/DockerApp.tsx (95%) rename {src/ui/features/docker => plugins/docker/frontend}/DockerManager.tsx (100%) rename {src/ui/features/docker => plugins/docker/frontend}/components/ConsoleTerminal.tsx (99%) rename {src/ui/features/docker => plugins/docker/frontend}/components/ContainerCard.tsx (99%) rename {src/ui/features/docker => plugins/docker/frontend}/components/ContainerDetail.tsx (100%) rename {src/ui/features/docker => plugins/docker/frontend}/components/ContainerList.tsx (100%) rename {src/ui/features/docker => plugins/docker/frontend}/components/ContainerStats.tsx (99%) rename {src/ui/features/docker => plugins/docker/frontend}/components/ContainerTable.tsx (99%) rename {src/ui/features/docker => plugins/docker/frontend}/components/LogViewer.tsx (99%) create mode 100644 plugins/docker/frontend/index.mjs create mode 100644 plugins/docker/manifest.json create mode 100644 plugins/package.json delete mode 100644 src/backend/hosts/docker/index.ts create mode 100644 src/ui/dashboard/dashboard-cards-registry.ts create mode 100644 src/ui/tests/dashboard/dashboard-cards-registry.test.ts create mode 100644 src/ui/tests/sidebar/host-editor-tab-seam.test.tsx create mode 100644 tsconfig.plugins.json diff --git a/.gitignore b/.gitignore index cfb12333d..427895808 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,14 @@ dist-ssr coverage *.local +# tsc -p tsconfig.plugins.json emits plugin backend .js next to its .ts +# source. It also stray-emits sibling .js next to every core .ts the plugin +# imports for type-checking (src/types, src/backend/utils, repositories...); +# scripts/copy-bundled-plugins.cjs removes those after every compile, this +# is a safety net for running tsc -p tsconfig.plugins.json directly. +/plugins/*/backend/*.js +/src/**/*.js + .vscode/* !.vscode/settings.json .idea diff --git a/src/backend/hosts/docker/console.ts b/plugins/docker/backend/console.ts similarity index 94% rename from src/backend/hosts/docker/console.ts rename to plugins/docker/backend/console.ts index fdd65b3de..95f41d0b0 100644 --- a/src/backend/hosts/docker/console.ts +++ b/plugins/docker/backend/console.ts @@ -1,32 +1,35 @@ -import { getErrorMessage } from "../../utils/error-message.js"; +// Core imports below point at TypeScript source so tsc can type-check them. +// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled +// output path after tsc -p tsconfig.plugins.json runs -- see that script. +import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; import { StringDecoder } from "string_decoder"; import { Client as SSHClient } from "ssh2"; -import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; +import { SSH_ALGORITHMS } from "../../../src/backend/utils/ssh-algorithms.js"; import { WebSocketServer, WebSocket } from "ws"; -import { AuthManager } from "../../utils/auth-manager.js"; -import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js"; -import { systemLogger } from "../../utils/logger.js"; -import type { SSHHost } from "../../../types/index.js"; -import { applyAgentAuth } from "../terminal-auth-helpers.js"; +import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; +import { createCurrentHostResolutionRepository } from "../../../src/backend/database/repositories/factory.js"; +import { systemLogger } from "../../../src/backend/utils/logger.js"; +import type { SSHHost } from "../../../src/types/index.js"; +import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js"; import { containerCommand, getContainerRuntimeConfig, type ContainerRuntime, } from "./container-runtime.js"; -import { resolveSshConnectConfigHost } from "../ssh-dns.js"; +import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js"; import { hostAddressMismatch, HOST_ADDRESS_MISMATCH_MESSAGE, HOST_NOT_ON_THIS_SERVER_MESSAGE, -} from "../host-identity.js"; -import { extractWebSocketToken } from "../../utils/ws-auth.js"; +} from "../../../src/backend/hosts/host-identity.js"; +import { extractWebSocketToken } from "../../../src/backend/utils/ws-auth.js"; import { asObject, asString, MAX_WS_MESSAGE_BYTES, parseWsMessage, toTerminalDimension, -} from "../../utils/ws-message.js"; +} from "../../../src/backend/utils/ws-message.js"; const sshLogger = systemLogger; @@ -404,7 +407,7 @@ wss.on("connection", async (ws: WebSocket, req) => { try { // Resolve host with credentials server-side const { resolveHostById, resolveHostBySyncId } = - await import("../host-resolver.js"); + await import("../../../src/backend/hosts/host-resolver.js"); // syncId names the host on both sides of a sync pair; the numeric // id only names it in the database the client is displaying. const hostSyncId = hostConfig?.syncId; @@ -823,7 +826,12 @@ wss.on("connection", async (ws: WebSocket, req) => { }); }); -process.on("SIGTERM", () => { +/** + * Closes every live console session and the WebSocket server. Called by the + * plugin's deactivate() as well as SIGTERM, since disabling the docker + * plugin has to free port 30009 the same way process shutdown does. + */ +export function closeConsoleServer(): Promise { activeSessions.forEach((session) => { if (session.stream) { session.stream.end(); @@ -833,7 +841,9 @@ process.on("SIGTERM", () => { activeSessions.clear(); - wss.close(() => { - process.exit(0); - }); + return new Promise((resolve) => wss.close(() => resolve())); +} + +process.on("SIGTERM", () => { + void closeConsoleServer().then(() => process.exit(0)); }); diff --git a/src/backend/hosts/docker/container-routes.ts b/plugins/docker/backend/container-routes.ts similarity index 98% rename from src/backend/hosts/docker/container-routes.ts rename to plugins/docker/backend/container-routes.ts index 4bc344f60..bdd2f37bf 100644 --- a/src/backend/hosts/docker/container-routes.ts +++ b/plugins/docker/backend/container-routes.ts @@ -1,6 +1,9 @@ -import { getErrorMessage } from "../../utils/error-message.js"; +// Core imports below point at TypeScript source so tsc can type-check them. +// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled +// output path after tsc -p tsconfig.plugins.json runs -- see that script. +import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; import type express from "express"; -import { logger } from "../../utils/logger.js"; +import { logger } from "../../../src/backend/utils/logger.js"; import { containerCommand, type ContainerRuntime, diff --git a/src/backend/hosts/docker/container-runtime.ts b/plugins/docker/backend/container-runtime.ts similarity index 100% rename from src/backend/hosts/docker/container-runtime.ts rename to plugins/docker/backend/container-runtime.ts diff --git a/plugins/docker/backend/index.mjs b/plugins/docker/backend/index.mjs new file mode 100644 index 000000000..f022dba55 --- /dev/null +++ b/plugins/docker/backend/index.mjs @@ -0,0 +1,142 @@ +/** + * Docker - first-party, in-process plugin. + * + * This plugin runs on the main thread rather than in a worker for the same + * reason ssh-terminal does (see src/backend/plugins/first-party.ts): its + * container-console WebSocket server holds long-lived ssh2.Client + PTY + * streams, its session-manager reuses a live ssh2.Client across many REST + * calls, and its SSH connect flow implements TOTP/Warpgate as a genuinely + * stateful multi-request handshake. None of that can cross the + * structured-clone postMessage boundary a worker plugin talks over. + * + * Unlike ssh-terminal, Docker's backend has no consumers outside itself, so + * the whole implementation moved here rather than staying in core with only + * a lifecycle wrapper. This file is what used to be + * src/backend/hosts/docker/index.ts, rewritten from a module-scope + * SIGINT/SIGTERM + listenOnServicePort() call into activate()/deactivate(). + * + * The moved files (routes.ts, container-routes.ts, container-runtime.ts, + * session-manager.ts, console.ts) are real TypeScript, compiled by + * scripts/copy-bundled-plugins.cjs (see that script for why plugins/ needs + * its own compile step) rather than hand-written like ssh-terminal's .mjs + * wrapper. This file itself stays plain JS since it is a thin entry point + * with no types worth checking. + */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer } from "node:http"; + +/** + * 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 the moved docker files (routes.js, session-manager.js, ...) sits + * right next to this .mjs, and core modules resolve via + * ../../../backend/backend/... from there (dist/plugins/docker/backend -> + * dist/backend/backend). Under vitest/dev nothing imports this plugin + * directly (src/backend/plugins/loader.ts 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 httpServer = null; +let consoleModule = null; +let authManagerInstance = null; + +export async function activate(ctx) { + const [ + express, + cookieParser, + { createCorsMiddleware }, + { createCompressionMiddleware }, + { logger }, + { AuthManager }, + { registerDockerContainerRoutes }, + sessionManager, + { DOCKER_TIMESTAMP_RE, getRequestUserId, registerDockerSshRoutes }, + ] = await Promise.all([ + import("express").then((m) => m.default), + import("cookie-parser").then((m) => m.default), + loadRelative("../../../backend/backend/utils/cors-config"), + loadRelative("../../../backend/backend/utils/compression-config"), + loadRelative("../../../backend/backend/utils/logger"), + loadRelative("../../../backend/backend/utils/auth-manager"), + loadRelative("container-routes"), + loadRelative("session-manager"), + loadRelative("routes"), + ]); + + const app = express(); + app.set("trust proxy", "loopback"); + + app.use(createCompressionMiddleware()); + app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"])); + + app.use(cookieParser()); + authManagerInstance = AuthManager.getInstance(); + app.use(authManagerInstance.createAuthMiddleware()); + app.use(express.json({ limit: "100mb" })); + app.use(express.urlencoded({ limit: "100mb", extended: true })); + app.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); + }); + + registerDockerSshRoutes(app); + registerDockerContainerRoutes(app, { + sshSessions: sessionManager.sshSessions, + pendingTOTPSessions: sessionManager.pendingTOTPSessions, + getRequestUserId, + executeDockerCommand: sessionManager.executeDockerCommand, + dockerTimestampPattern: DOCKER_TIMESTAMP_RE, + }); + + const port = 30007; + const server = createServer(app); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + httpServer = server; + + try { + await authManagerInstance.initialize(); + } catch (err) { + logger.error("Failed to initialize Docker backend", err, { + operation: "startup", + }); + } + + // Importing console.js binds its WebSocket server on port 30009 as a + // module-scope side effect -- unchanged from before the move, see that + // file's own header. + consoleModule = await loadRelative("console"); + + ctx.log.info(`Docker plugin listening on ${port} (console on 30009)`); +} + +export async function deactivate() { + if (consoleModule?.closeConsoleServer) { + await consoleModule.closeConsoleServer(); + } + consoleModule = null; + + if (httpServer) { + const server = httpServer; + httpServer = null; + await new Promise((resolve) => server.close(() => resolve())); + } + + authManagerInstance = null; +} diff --git a/src/backend/hosts/docker/routes.ts b/plugins/docker/backend/routes.ts similarity index 96% rename from src/backend/hosts/docker/routes.ts rename to plugins/docker/backend/routes.ts index 3c36ed6c9..80786ab4c 100644 --- a/src/backend/hosts/docker/routes.ts +++ b/plugins/docker/backend/routes.ts @@ -1,42 +1,45 @@ -import { getErrorMessage } from "../../utils/error-message.js"; -import { usesIssuedCertificate } from "../issued-certificate-auth.js"; +// Core imports below point at TypeScript source so tsc can type-check them. +// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled +// output path after tsc -p tsconfig.plugins.json runs -- see that script. +import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; +import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js"; import express from "express"; import axios from "axios"; import { Client as SSHClient } from "ssh2"; -import { logger } from "../../utils/logger.js"; +import { logger } from "../../../src/backend/utils/logger.js"; import { logAudit, getAuditUsername, getRequestMeta, -} from "../../utils/audit-logger.js"; -import { createCurrentHostRepository } from "../../database/repositories/factory.js"; -import { createJumpHostChain } from "../jump-host-chain.js"; -import { resolveHostById } from "../host-resolver.js"; -import { createConnectionLog } from "../connection-log.js"; -import { DataCrypto } from "../../utils/data-crypto.js"; -import { AuthManager } from "../../utils/auth-manager.js"; -import { - type AuthenticatedRequest, - type ProxyNode, - type SSHHost, -} from "../../../types/index.js"; +} from "../../../src/backend/utils/audit-logger.js"; +import { createCurrentHostRepository } from "../../../src/backend/database/repositories/factory.js"; +import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js"; +import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js"; +import { createConnectionLog } from "../../../src/backend/hosts/connection-log.js"; +import { DataCrypto } from "../../../src/backend/utils/data-crypto.js"; +import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; +import type { + AuthenticatedRequest, + ProxyNode, + SSHHost, +} from "../../../src/types/index.js"; import { createSocks5Connection, type SOCKS5Config, -} from "../../utils/socks5-helper.js"; +} from "../../../src/backend/utils/socks5-helper.js"; import type { LogEntry, ConnectionStage, -} from "../../../types/connection-log.js"; -import { SSHHostKeyVerifier } from "../host-key-verifier.js"; -import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; -import { applyAgentAuth } from "../terminal-auth-helpers.js"; +} from "../../../src/types/connection-log.js"; +import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js"; +import { preparePrivateKeyForSSH2 } from "../../../src/backend/utils/ssh-key-utils.js"; +import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js"; import { containerCommand, getContainerRuntimeConfig, getRuntimeLabel, } from "./container-runtime.js"; -import { resolveSshConnectConfigHost } from "../ssh-dns.js"; +import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js"; import { type SSHSession, sshSessions, @@ -303,7 +306,8 @@ export function registerDockerSshRoutes(app: express.Express): void { } } else if (usesIssuedCertificate(resolvedCredentials.authType)) { try { - const { getOPKSSHToken } = await import("../opkssh-auth.js"); + const { getOPKSSHToken } = + await import("../../../src/backend/hosts/opkssh-auth.js"); const token = await getOPKSSHToken(userId, hostId); if (!token) { @@ -323,7 +327,7 @@ export function registerDockerSshRoutes(app: express.Express): void { } const { setupOPKSSHCertAuth } = - await import("../opkssh-cert-auth.js"); + await import("../../../src/backend/hosts/opkssh-cert-auth.js"); await setupOPKSSHCertAuth( config as import("ssh2").ConnectConfig, client, diff --git a/src/backend/hosts/docker/session-manager.ts b/plugins/docker/backend/session-manager.ts similarity index 93% rename from src/backend/hosts/docker/session-manager.ts rename to plugins/docker/backend/session-manager.ts index 6a620db17..bcc58d51b 100644 --- a/src/backend/hosts/docker/session-manager.ts +++ b/plugins/docker/backend/session-manager.ts @@ -1,5 +1,8 @@ +// Core imports below point at TypeScript source so tsc can type-check them. +// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled +// output path after tsc -p tsconfig.plugins.json runs -- see that script. import { Client as SSHClient } from "ssh2"; -import { logger } from "../../utils/logger.js"; +import { logger } from "../../../src/backend/utils/logger.js"; import type { ContainerRuntime } from "./container-runtime.js"; const sshLogger = logger; diff --git a/src/ui/features/docker/DockerApp.tsx b/plugins/docker/frontend/DockerApp.tsx similarity index 95% rename from src/ui/features/docker/DockerApp.tsx rename to plugins/docker/frontend/DockerApp.tsx index c1e3c29c8..40e74e0f2 100644 --- a/src/ui/features/docker/DockerApp.tsx +++ b/plugins/docker/frontend/DockerApp.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { DockerManager } from "@/features/docker/DockerManager.tsx"; +import { DockerManager } from "./DockerManager.tsx"; import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx"; import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx"; diff --git a/src/ui/features/docker/DockerManager.tsx b/plugins/docker/frontend/DockerManager.tsx similarity index 100% rename from src/ui/features/docker/DockerManager.tsx rename to plugins/docker/frontend/DockerManager.tsx diff --git a/src/ui/features/docker/components/ConsoleTerminal.tsx b/plugins/docker/frontend/components/ConsoleTerminal.tsx similarity index 99% rename from src/ui/features/docker/components/ConsoleTerminal.tsx rename to plugins/docker/frontend/components/ConsoleTerminal.tsx index 847217e8f..1d8577fb6 100644 --- a/src/ui/features/docker/components/ConsoleTerminal.tsx +++ b/plugins/docker/frontend/components/ConsoleTerminal.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../../../lib/error-message.js"; +import { getErrorMessage } from "@/lib/error-message.js"; import React from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; diff --git a/src/ui/features/docker/components/ContainerCard.tsx b/plugins/docker/frontend/components/ContainerCard.tsx similarity index 99% rename from src/ui/features/docker/components/ContainerCard.tsx rename to plugins/docker/frontend/components/ContainerCard.tsx index 9b6848db6..1b3b32bbb 100644 --- a/src/ui/features/docker/components/ContainerCard.tsx +++ b/plugins/docker/frontend/components/ContainerCard.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../../../lib/error-message.js"; +import { getErrorMessage } from "@/lib/error-message.js"; import React from "react"; import { Card } from "@/components/card.tsx"; import { Button } from "@/components/button.tsx"; diff --git a/src/ui/features/docker/components/ContainerDetail.tsx b/plugins/docker/frontend/components/ContainerDetail.tsx similarity index 100% rename from src/ui/features/docker/components/ContainerDetail.tsx rename to plugins/docker/frontend/components/ContainerDetail.tsx diff --git a/src/ui/features/docker/components/ContainerList.tsx b/plugins/docker/frontend/components/ContainerList.tsx similarity index 100% rename from src/ui/features/docker/components/ContainerList.tsx rename to plugins/docker/frontend/components/ContainerList.tsx diff --git a/src/ui/features/docker/components/ContainerStats.tsx b/plugins/docker/frontend/components/ContainerStats.tsx similarity index 99% rename from src/ui/features/docker/components/ContainerStats.tsx rename to plugins/docker/frontend/components/ContainerStats.tsx index 579260ccb..b30334088 100644 --- a/src/ui/features/docker/components/ContainerStats.tsx +++ b/plugins/docker/frontend/components/ContainerStats.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../../../lib/error-message.js"; +import { getErrorMessage } from "@/lib/error-message.js"; import React from "react"; import { Activity, diff --git a/src/ui/features/docker/components/ContainerTable.tsx b/plugins/docker/frontend/components/ContainerTable.tsx similarity index 99% rename from src/ui/features/docker/components/ContainerTable.tsx rename to plugins/docker/frontend/components/ContainerTable.tsx index 5d8e07179..cfb873d0e 100644 --- a/src/ui/features/docker/components/ContainerTable.tsx +++ b/plugins/docker/frontend/components/ContainerTable.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../../../lib/error-message.js"; +import { getErrorMessage } from "@/lib/error-message.js"; import React from "react"; import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react"; import { toast } from "sonner"; diff --git a/src/ui/features/docker/components/LogViewer.tsx b/plugins/docker/frontend/components/LogViewer.tsx similarity index 99% rename from src/ui/features/docker/components/LogViewer.tsx rename to plugins/docker/frontend/components/LogViewer.tsx index 98bb245c7..54e489146 100644 --- a/src/ui/features/docker/components/LogViewer.tsx +++ b/plugins/docker/frontend/components/LogViewer.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../../../lib/error-message.js"; +import { getErrorMessage } from "@/lib/error-message.js"; import React from "react"; import { Button } from "@/components/button.tsx"; import { Input } from "@/components/input.tsx"; diff --git a/plugins/docker/frontend/index.mjs b/plugins/docker/frontend/index.mjs new file mode 100644 index 000000000..0575e87f3 --- /dev/null +++ b/plugins/docker/frontend/index.mjs @@ -0,0 +1,68 @@ +/** + * Frontend half of the docker plugin. + * + * Registers the container-management tab through the existing extension + * seams (registerRailItem / registerTabComponent) the same way + * ssh-terminal's frontend does, and the host-editor Docker tab through the + * new registerHostEditorTab seam built for this plugin. + * + * HostDockerTab itself stays in src/ui/sidebar/HostEditorFeatureTabs.tsx + * rather than moving into this plugin: it takes the same {form, setField} + * pair every other host editor tab does, and those types (HostEditorForm, + * the setField signature) are core types HostEditor.tsx owns, the same + * reason ssh-terminal leaves session-manager.ts in core rather than copying + * it here. + * + * register() is called when the plugin is enabled, unregister() when it is + * disabled. As of this change neither is actually invoked anywhere yet -- + * 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" for the frontend half. This file follows + * the same shape so it is ready the moment that loader exists. + */ + +export const id = "docker"; +export const tabId = "docker"; +export const hostEditorTabId = "docker"; + +export async function register({ + registerRailItem, + registerTabComponent, + registerHostEditorTab, + icons, +}) { + registerTabComponent(tabId, () => + import("./DockerManager.tsx").then((m) => ({ + default: m.DockerManager, + })), + ); + + registerRailItem({ + id: tabId, + icon: icons.Box, + labelKey: "nav.docker", + kind: "tab", + }); + + if (registerHostEditorTab) { + const { HostDockerTab } = await import( + "../../../src/ui/sidebar/HostEditorFeatureTabs.tsx" + ); + registerHostEditorTab({ + id: hostEditorTabId, + labelKey: "hosts.tabDocker", + icon: icons.Box, + component: HostDockerTab, + }); + } +} + +export async function unregister({ + unregisterRailItem, + unregisterTabComponent, + unregisterHostEditorTab, +}) { + unregisterRailItem(tabId); + unregisterTabComponent(tabId); + unregisterHostEditorTab?.(hostEditorTabId); +} diff --git a/plugins/docker/manifest.json b/plugins/docker/manifest.json new file mode 100644 index 000000000..d5479b956 --- /dev/null +++ b/plugins/docker/manifest.json @@ -0,0 +1,35 @@ +{ + "id": "docker", + "name": "Docker", + "version": "1.0.0", + "description": "Container management over SSH: list, inspect, start, stop and view logs for Docker or Podman containers, plus an interactive container console.", + "author": { "name": "Termix" }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Infrastructure", + "icon": "Box", + "engine": { "termix": ">=2.9.0", "api": "1" }, + "capabilities": { + "backend": true, + "frontend": true, + "electron": true, + "platforms": ["linux", "win32", "darwin"] + }, + "permissions": ["process:transport-owner"], + "contributes": { + "tabs": [ + { + "id": "docker", + "titleKey": "nav.docker", + "icon": "Box", + "openFrom": ["host-context-menu", "palette"] + } + ], + "hostCapability": { + "key": "enableDocker", + "labelKey": "hosts.dockerIntegration", + "editorTab": "docker" + } + }, + "sidecars": [] +} diff --git a/plugins/package.json b/plugins/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/plugins/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/scripts/copy-bundled-plugins.cjs b/scripts/copy-bundled-plugins.cjs index da271b33f..0586f96d4 100644 --- a/scripts/copy-bundled-plugins.cjs +++ b/scripts/copy-bundled-plugins.cjs @@ -1,10 +1,24 @@ /** - * Copies plugins/ into dist/plugins so bundled first-party plugins ship with a - * built server. + * Compiles plugin backend TypeScript, then copies plugins/ into dist/plugins + * so bundled first-party plugins ship with a built server. * - * tsc only emits .ts, and a plugin's backend entry is a hand-written .mjs plus - * a manifest.json, so without this step the plugins directory simply would not - * exist in dist and the loader would find nothing. + * Most plugin backend entries are hand-written .mjs plus a manifest.json -- + * tsc only emits .ts, so without a copy step the plugins directory simply + * would not exist in dist and the loader would find nothing. + * + * The docker plugin is the exception: its backend is real TypeScript, + * physically relocated from src/backend/hosts/docker/ rather than kept as + * hand-written JS, because it is ~4000 lines of typed SSH/session logic that + * is not worth hand-transpiling. tsconfig.plugins.json compiles the plugin + * backend TypeScript to a sibling .js next to its source. Its imports + * into core (e.g. "../../../src/backend/utils/logger.js") are written + * relative to the TypeScript SOURCE tree so tsc can type-check them, since + * plugins/ is not under tsconfig.node.json's rootDir and cannot be added to + * it without changing every existing dist/backend/backend/... path in the + * codebase. That means the emitted .js still points at src/backend/, which + * does not exist in a built server -- only its compiled counterpart at + * dist/backend/backend/ does. rewriteCoreImports() below corrects that one + * prefix after compilation, before the directory is copied into dist/. * * getBundledPluginsDir() in src/backend/plugins/paths.ts resolves * dist/backend/backend/plugins -> dist/plugins, which is where this writes. @@ -12,6 +26,7 @@ const fs = require("node:fs"); const path = require("node:path"); +const { execSync } = require("node:child_process"); const root = path.resolve(__dirname, ".."); const source = path.join(root, "plugins"); @@ -22,6 +37,84 @@ if (!fs.existsSync(source)) { process.exit(0); } +const SRC_BACKEND_PREFIX = "../../../src/backend/"; +const COMPILED_BACKEND_PREFIX = "../../../backend/backend/"; + +function compilePluginBackends() { + execSync("npx tsc -p tsconfig.plugins.json", { + cwd: root, + stdio: "inherit", + }); +} + +/** + * Rewrites the compiled plugin JS's imports into core from the source-tree + * path tsc needed to resolve them, to the compiled-output path they need to + * resolve at runtime. See the module comment above for why this exists. + */ +function rewriteCoreImports(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + rewriteCoreImports(entryPath); + continue; + } + if (!entry.name.endsWith(".js")) continue; + + const original = fs.readFileSync(entryPath, "utf8"); + if (!original.includes(SRC_BACKEND_PREFIX)) continue; + + const rewritten = original.split(SRC_BACKEND_PREFIX).join(COMPILED_BACKEND_PREFIX); + fs.writeFileSync(entryPath, rewritten); + } +} + +/** + * tsconfig.plugins.json has rootDir/outDir "." so the docker plugin's .js + * lands beside its own .ts, but tsc also pulls every file the plugin + * imports into the same program for type-checking -- core utils, repository + * factories, src/types -- and stray-emits a sibling .js next to each of + * those .ts files too, even though none of that code is actually compiled + * by this pass (only referenced for types/signatures). Those stray files + * are not part of the normal build and must not leak into dist/ or the + * working tree, so anything under src/ that is a .js with no git history + * and a same-named .ts sibling gets removed after every compile. + */ +function isGitTracked(filePath) { + try { + execSync(`git ls-files --error-unmatch "${filePath}"`, { + cwd: root, + stdio: "ignore", + }); + return true; + } catch { + return false; + } +} + +function removeStrayEmits(dir) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + removeStrayEmits(entryPath); + continue; + } + if (!entry.name.endsWith(".js")) continue; + const tsSibling = entryPath.slice(0, -".js".length) + ".ts"; + // Never touch a tracked file, even if some future .ts/.js pair + // legitimately coexists -- only remove what is both untracked and has a + // .ts sibling, which is what a stray tsc emit always looks like. + if (fs.existsSync(tsSibling) && !isGitTracked(entryPath)) { + fs.rmSync(entryPath); + } + } +} + +compilePluginBackends(); +rewriteCoreImports(path.join(source, "docker", "backend")); +removeStrayEmits(path.join(root, "src")); + fs.rmSync(destination, { recursive: true, force: true }); fs.cpSync(source, destination, { recursive: true }); diff --git a/src/backend/hosts/docker/index.ts b/src/backend/hosts/docker/index.ts deleted file mode 100644 index d06d13786..000000000 --- a/src/backend/hosts/docker/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -import express from "express"; -import cookieParser from "cookie-parser"; -import { createCorsMiddleware } from "../../utils/cors-config.js"; -import { createCompressionMiddleware } from "../../utils/compression-config.js"; -import { logger } from "../../utils/logger.js"; -import { AuthManager } from "../../utils/auth-manager.js"; -import { registerDockerContainerRoutes } from "./container-routes.js"; -import { - sshSessions, - pendingTOTPSessions, - cleanupSession, - executeDockerCommand, -} from "./session-manager.js"; -import { - DOCKER_TIMESTAMP_RE, - getRequestUserId, - registerDockerSshRoutes, -} from "./routes.js"; -import { listenOnServicePort } from "../../utils/service-listen.js"; - -const sshLogger = logger; - -const app = express(); -app.set("trust proxy", "loopback"); - -app.use(createCompressionMiddleware()); -app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"])); - -app.use(cookieParser()); -const authManager = AuthManager.getInstance(); -app.use(authManager.createAuthMiddleware()); -app.use(express.json({ limit: "100mb" })); -app.use(express.urlencoded({ limit: "100mb", extended: true })); -app.use((_req, res, next) => { - res.setHeader("Cache-Control", "no-store"); - next(); -}); - -registerDockerSshRoutes(app); - -registerDockerContainerRoutes(app, { - sshSessions, - pendingTOTPSessions, - getRequestUserId, - executeDockerCommand, - dockerTimestampPattern: DOCKER_TIMESTAMP_RE, -}); - -const PORT = 30007; - -listenOnServicePort({ - app, - port: PORT, - logger: sshLogger, - serviceName: "docker", - onListening: async () => { - try { - await authManager.initialize(); - } catch (err) { - sshLogger.error("Failed to initialize Docker backend", err, { - operation: "startup", - }); - } - }, -}); - -process.on("SIGINT", () => { - Object.keys(sshSessions).forEach((sessionId) => { - cleanupSession(sessionId); - }); - process.exit(0); -}); - -process.on("SIGTERM", () => { - Object.keys(sshSessions).forEach((sessionId) => { - cleanupSession(sessionId); - }); - process.exit(0); -}); diff --git a/src/backend/plugins/first-party.ts b/src/backend/plugins/first-party.ts index 0445a4246..74a643ad8 100644 --- a/src/backend/plugins/first-party.ts +++ b/src/backend/plugins/first-party.ts @@ -32,9 +32,16 @@ * ssh-terminal is here because it IS the SSH transport rather than a consumer * of it: it owns a WebSocket server and long-lived ssh2 clients, neither of * which can cross a postMessage boundary. + * + * docker is here for the same reason: its container console owns a + * WebSocket server with long-lived ssh2 clients and PTY streams, its + * session manager reuses a live ssh2.Client across many REST calls, and its + * SSH connect flow implements TOTP/Warpgate as a stateful multi-request + * handshake the worker ctx.ssh API has no hook for. */ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "ssh-terminal", + "docker", ]); export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner"; diff --git a/src/backend/starter.ts b/src/backend/starter.ts index c2389b196..971c2d976 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -295,13 +295,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { const { serverReady } = await import("./database/database.js"); await serverReady; - // Terminal is deliberately absent: the ssh-terminal plugin starts it, so - // disabling that plugin stops the WS server. See plugins/ssh-terminal. + // Terminal and docker are deliberately absent: the ssh-terminal and + // docker plugins start their own servers, so disabling either plugin + // stops its WS/HTTP server. See plugins/ssh-terminal and plugins/docker. await import("./hosts/tunnel/index.js"); await import("./hosts/file-manager/index.js"); await import("./hosts/metrics/index.js"); - await import("./hosts/docker/index.js"); - await import("./hosts/docker/console.js"); await import("./hosts/tmux/index.js"); await import("./hosts/serial.js"); await import("./services/dashboard.js"); diff --git a/src/backend/tests/plugins/bundled-plugin.test.ts b/src/backend/tests/plugins/bundled-plugin.test.ts index 167244755..ac1a68351 100644 --- a/src/backend/tests/plugins/bundled-plugin.test.ts +++ b/src/backend/tests/plugins/bundled-plugin.test.ts @@ -82,3 +82,57 @@ describe("bundled ssh-terminal plugin", () => { expect(source).toMatch(/export async function deactivate/); }); }); + +describe("bundled docker plugin", () => { + it("ships a directory with a manifest and both entry points", () => { + const dir = path.join(getBundledPluginsDir(), "docker"); + + 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("docker")); + + expect(errors).toEqual([]); + expect(manifest?.id).toBe("docker"); + expect(manifest?.category).toBe("Infrastructure"); + }); + + it("declares the transport-owner capability and qualifies for the tier", () => { + const { manifest } = parseManifest(readBundledManifest("docker")); + + expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); + expect(isFirstParty("docker")).toBe(true); + expect(runsInProcess("docker", manifest!.permissions)).toBe(true); + }); + + it("contributes the docker tab the shell registers", () => { + const { manifest } = parseManifest(readBundledManifest("docker")); + const tab = manifest?.contributes?.tabs?.[0]; + + // The shell keys tab content off this id, so it has to stay "docker". + expect(tab?.id).toBe("docker"); + expect(tab?.openFrom).toContain("host-context-menu"); + }); + + it("declares the enableDocker host capability", () => { + const { manifest } = parseManifest(readBundledManifest("docker")); + + expect(manifest?.contributes?.hostCapability?.key).toBe("enableDocker"); + }); + + it("exports activate and deactivate from its backend entry", () => { + const entry = path.join( + getBundledPluginsDir(), + "docker", + "backend", + "index.mjs", + ); + const source = fs.readFileSync(entry, "utf8"); + + expect(source).toMatch(/export async function activate/); + expect(source).toMatch(/export async function deactivate/); + }); +}); diff --git a/src/backend/tests/plugins/first-party.test.ts b/src/backend/tests/plugins/first-party.test.ts index 1b7b4c541..6e64c0582 100644 --- a/src/backend/tests/plugins/first-party.test.ts +++ b/src/backend/tests/plugins/first-party.test.ts @@ -39,12 +39,16 @@ function manifest(overrides: Record = {}) { } describe("first-party allowlist", () => { - it("contains only ssh-terminal", () => { - expect([...FIRST_PARTY_PLUGIN_IDS]).toEqual(["ssh-terminal"]); + it("contains only ssh-terminal and docker", () => { + expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([ + "docker", + "ssh-terminal", + ]); }); - it("recognises ssh-terminal and nothing else", () => { + it("recognises ssh-terminal and docker and nothing else", () => { expect(isFirstParty("ssh-terminal")).toBe(true); + expect(isFirstParty("docker")).toBe(true); expect(isFirstParty("ssh-terminal-pro")).toBe(false); expect(isFirstParty("community-plugin")).toBe(false); expect(isFirstParty("")).toBe(false); @@ -56,10 +60,12 @@ describe("runsInProcess", () => { expect(runsInProcess("ssh-terminal", [TRANSPORT_OWNER_CAPABILITY])).toBe( true, ); + expect(runsInProcess("docker", [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. expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false); + expect(runsInProcess("docker", ["hosts.read"])).toBe(false); // Asking for it but not on the list. expect( @@ -103,6 +109,18 @@ describe("manifest gate on the reserved capability", () => { expect(parsed?.id).toBe("ssh-terminal"); }); + it("allows it for docker", () => { + const { manifest: parsed, errors } = parseManifest( + manifest({ + id: "docker", + permissions: [TRANSPORT_OWNER_CAPABILITY], + }), + ); + + expect(errors).toEqual([]); + expect(parsed?.id).toBe("docker"); + }); + it("still accepts an ordinary manifest that never mentions it", () => { const { manifest: parsed, errors } = parseManifest( manifest({ permissions: ["hosts.read"] }), diff --git a/src/main.tsx b/src/main.tsx index 00d400bc3..f09e572de 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -54,7 +54,9 @@ const ProxmoxStatsApp = lazy(() => })), ); const DockerApp = lazy(() => - import("@/features/docker/DockerApp").then((m) => ({ default: m.default })), + import("../plugins/docker/frontend/DockerApp").then((m) => ({ + default: m.default, + })), ); const GuacamoleApp = lazy(() => import("@/features/guacamole/GuacamoleApp").then((m) => ({ diff --git a/src/ui/dashboard/dashboard-cards-registry.ts b/src/ui/dashboard/dashboard-cards-registry.ts new file mode 100644 index 000000000..8ad71a805 --- /dev/null +++ b/src/ui/dashboard/dashboard-cards-registry.ts @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; + +/** + * Runtime registry for plugin-contributed dashboard cards. + * + * Unlike registerRailItem/registerTabComponent (rail-items.ts, tabUtils.tsx), + * nothing in DashboardTab.tsx consumes this yet: its card system + * (DASHBOARD_CARDS in @/lib/theme, DashboardCardId in @/types/ui-types) is a + * closed, typed set with its own drag/drop layout and per-user persisted + * slot preferences, and no plugin today contributes an actual dashboard + * card. This registry exists so a plugin CAN register one without another + * round of seam-building, but adding it to the DashboardTab render/layout + * system is future work, not part of this change. + */ +export interface RegisteredDashboardCard { + id: string; + titleKey: string; + render: () => ReactNode; +} + +const registeredDashboardCards = new Map(); + +export function registerDashboardCard(def: RegisteredDashboardCard): void { + registeredDashboardCards.set(def.id, def); +} + +export function unregisterDashboardCard(id: string): void { + registeredDashboardCards.delete(id); +} + +export function registeredDashboardCardList(): RegisteredDashboardCard[] { + return [...registeredDashboardCards.values()]; +} diff --git a/src/ui/features/homepage/widgets/DockerWidget.tsx b/src/ui/features/homepage/widgets/DockerWidget.tsx index 2c6cf8a45..9e30ca7b0 100644 --- a/src/ui/features/homepage/widgets/DockerWidget.tsx +++ b/src/ui/features/homepage/widgets/DockerWidget.tsx @@ -9,7 +9,7 @@ import type { import { GRID_SIZE } from "@/types/homepage-types"; import { getSSHHosts } from "@/api/ssh-host-management-api"; import type { SSHHostWithStatus } from "@/main-axios"; -import { DockerManager } from "@/features/docker/DockerManager"; +import { DockerManager } from "../../../../../plugins/docker/frontend/DockerManager"; import type { SSHHost } from "@/types/index"; import { WidgetTitle } from "./WidgetTitle"; diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 546fc5d44..1b5c6eb7b 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -85,7 +85,7 @@ const loadFileManager = () => })); const FileManager = lazy(loadFileManager); const loadDockerManager = () => - import("@/features/docker/DockerManager").then((m) => ({ + import("../../../plugins/docker/frontend/DockerManager").then((m) => ({ default: m.DockerManager, })); const DockerManager = lazy(loadDockerManager); diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index d2244c4ea..0a534ff01 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -95,6 +95,7 @@ import { } from "./HostEditorGuacamoleTabs"; import { HostStatsTab } from "./HostEditorStatsTab"; import { VaultProfileManager } from "./VaultProfileManager"; +import { getRegisteredHostEditorTab } from "./HostManagerTabs"; import { SecretReferenceHint, SecretSourceManager, @@ -2661,6 +2662,13 @@ export function HostEditor({ credentials={availableCredentials} /> )} + + {(() => { + const registeredTab = getRegisteredHostEditorTab(activeTab); + if (!registeredTab) return null; + const RegisteredTabComponent = registeredTab.component; + return ; + })()} diff --git a/src/ui/sidebar/HostManagerTabs.tsx b/src/ui/sidebar/HostManagerTabs.tsx index 860e25058..5fe5a4af3 100644 --- a/src/ui/sidebar/HostManagerTabs.tsx +++ b/src/ui/sidebar/HostManagerTabs.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-refresh/only-export-components */ -import { useEffect, useRef, type ReactNode } from "react"; +import { useEffect, useRef, type ComponentType, type ReactNode } from "react"; import { Activity, Box, @@ -52,6 +52,50 @@ export const SSH_GROUP_TABS = new Set([ "host-metrics", ]); +/** + * A plugin-contributed host editor tab. `component` receives the same + * {form, setField} pair every built-in tab body gets (see HostDockerTab in + * HostEditorFeatureTabs.tsx for the shape a plugin's own tab component + * should match). + */ +export type HostEditorTabComponent = ComponentType<{ + form: unknown; + setField: (key: string, value: unknown) => void; +}>; + +interface RegisteredHostEditorTab { + id: string; + labelKey: string; + icon: ReactNode; + component: HostEditorTabComponent; +} + +/** + * Runtime registry for host editor tabs that don't exist in the built-in + * HostTabId union. A plugin's frontend registers into this from its own + * register() (see plugins/*\/frontend/index.mjs), the same pattern + * rail-items.ts and tabUtils.tsx already use for their seams. + */ +const registeredHostEditorTabs = new Map(); + +export function registerHostEditorTab(def: RegisteredHostEditorTab): void { + registeredHostEditorTabs.set(def.id, def); +} + +export function unregisterHostEditorTab(id: string): void { + registeredHostEditorTabs.delete(id); +} + +export function getRegisteredHostEditorTab( + id: string, +): RegisteredHostEditorTab | undefined { + return registeredHostEditorTabs.get(id); +} + +export function registeredHostEditorTabList(): RegisteredHostEditorTab[] { + return [...registeredHostEditorTabs.values()]; +} + export function makeHostTabs(t: (key: string) => string): HostTab[] { return [ { @@ -124,6 +168,11 @@ export function makeHostSshSubTabs(t: (key: string) => string): HostTab[] { label: t("hosts.tabHostMetrics"), icon: , }, + ...registeredHostEditorTabList().map((tab) => ({ + id: tab.id as HostTabId, + label: t(tab.labelKey), + icon: tab.icon, + })), ]; } diff --git a/src/ui/tests/dashboard/dashboard-cards-registry.test.ts b/src/ui/tests/dashboard/dashboard-cards-registry.test.ts new file mode 100644 index 000000000..0e4352821 --- /dev/null +++ b/src/ui/tests/dashboard/dashboard-cards-registry.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + registerDashboardCard, + registeredDashboardCardList, + unregisterDashboardCard, +} from "@/dashboard/dashboard-cards-registry"; + +const FAKE_CARD_ID = "__test_plugin_dashboard_card__"; + +describe("registerDashboardCard seam", () => { + afterEach(() => { + unregisterDashboardCard(FAKE_CARD_ID); + }); + + it("is not registered by default", () => { + expect(registeredDashboardCardList()).toEqual([]); + }); + + it("registers and unregisters a plugin dashboard card", () => { + registerDashboardCard({ + id: FAKE_CARD_ID, + titleKey: "nav.fakePluginItem", + render: () => null, + }); + + expect(registeredDashboardCardList().map((c) => c.id)).toContain( + FAKE_CARD_ID, + ); + + unregisterDashboardCard(FAKE_CARD_ID); + + expect(registeredDashboardCardList().map((c) => c.id)).not.toContain( + FAKE_CARD_ID, + ); + }); +}); diff --git a/src/ui/tests/sidebar/host-editor-tab-seam.test.tsx b/src/ui/tests/sidebar/host-editor-tab-seam.test.tsx new file mode 100644 index 000000000..4806e15db --- /dev/null +++ b/src/ui/tests/sidebar/host-editor-tab-seam.test.tsx @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Puzzle } from "lucide-react"; +import { + getRegisteredHostEditorTab, + makeHostSshSubTabs, + registerHostEditorTab, + registeredHostEditorTabList, + unregisterHostEditorTab, +} from "@/sidebar/HostManagerTabs"; + +const FAKE_TAB_ID = "__test_plugin_host_editor_tab__"; + +function FakeTabComponent() { + return null; +} + +describe("registerHostEditorTab seam", () => { + afterEach(() => { + unregisterHostEditorTab(FAKE_TAB_ID); + }); + + it("is not registered by default", () => { + expect(getRegisteredHostEditorTab(FAKE_TAB_ID)).toBeUndefined(); + expect(registeredHostEditorTabList()).toEqual([]); + }); + + it("registers and unregisters a plugin host editor tab", () => { + registerHostEditorTab({ + id: FAKE_TAB_ID, + labelKey: "nav.fakePluginItem", + icon: , + component: FakeTabComponent, + }); + + expect(getRegisteredHostEditorTab(FAKE_TAB_ID)?.component).toBe( + FakeTabComponent, + ); + expect(registeredHostEditorTabList().map((t) => t.id)).toContain( + FAKE_TAB_ID, + ); + + unregisterHostEditorTab(FAKE_TAB_ID); + + expect(getRegisteredHostEditorTab(FAKE_TAB_ID)).toBeUndefined(); + }); + + it("appends a registered tab after the built-in SSH sub-tabs", () => { + registerHostEditorTab({ + id: FAKE_TAB_ID, + labelKey: "nav.fakePluginItem", + icon: , + component: FakeTabComponent, + }); + + const ids: string[] = makeHostSshSubTabs((key) => key).map((tab) => tab.id); + expect(ids).toContain(FAKE_TAB_ID); + expect(ids.indexOf(FAKE_TAB_ID)).toBe(ids.length - 1); + }); +}); diff --git a/tsconfig.plugins.json b/tsconfig.plugins.json new file mode 100644 index 000000000..35df1c0e5 --- /dev/null +++ b/tsconfig.plugins.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.plugins.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "nodenext", + "skipLibCheck": true, + "moduleResolution": "nodenext", + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "esModuleInterop": true, + "noEmit": false, + "rootDir": ".", + "outDir": ".", + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": false, + "noFallthroughCasesInSwitch": false, + "noUncheckedSideEffectImports": false, + "noImplicitAny": false, + "noImplicitThis": false, + "noUncheckedIndexedAccess": false, + "exactOptionalPropertyTypes": false, + "noPropertyAccessFromIndexSignature": false, + "allowUnusedLabels": true, + "allowUnreachableCode": true + }, + "include": ["plugins/docker/backend/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +}