feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
+22
View File
@@ -0,0 +1,22 @@
# Docker
Container management over SSH: list, inspect, start, stop and view logs for Docker or Podman containers, plus an interactive container console.
Bundled with Termix and enabled by default.
## Layout
manifest.json id, capabilities and what this plugin contributes
src/backend/ activate(ctx) / deactivate(), plus the routes
src/frontend/ the UI it registers
locales/en.json its strings (English only; the rest are translated)
tests/backend/ vitest, node
tests/frontend/ vitest, jsdom
## Commands
npm run build bundle into dist/
npm run test run this plugin's tests
npm run typecheck
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
-143
View File
@@ -1,143 +0,0 @@
/**
* 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 no longer binds anything: the server is started
// here so disabling the plugin frees port 30009 and re-enabling can take
// it again, even though the module stays in the ESM cache.
consoleModule = await loadRelative("console");
await consoleModule.startConsoleServer();
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;
}
+1
View File
@@ -0,0 +1 @@
{}
-2
View File
@@ -37,8 +37,6 @@
"editorTab": "docker"
}
},
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/docker",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
@@ -1,35 +1,35 @@
// 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";
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { StringDecoder } from "string_decoder";
import { Client as SSHClient } from "ssh2";
import { SSH_ALGORITHMS } from "../../../src/backend/utils/ssh-algorithms.js";
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
import { WebSocketServer, WebSocket } from "ws";
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 { 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 "../../../src/backend/hosts/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 "../../../src/backend/hosts/host-identity.js";
import { extractWebSocketToken } from "../../../src/backend/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 "../../../src/backend/utils/ws-message.js";
} from "../../../../src/backend/utils/ws-message.js";
const sshLogger = systemLogger;
@@ -450,7 +450,7 @@ async function onConsoleConnection(
try {
// Resolve host with credentials server-side
const { resolveHostById, resolveHostBySyncId } =
await import("../../../src/backend/hosts/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;
@@ -1,9 +1,9 @@
// 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";
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import type express from "express";
import { logger } from "../../../src/backend/utils/logger.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import {
containerCommand,
type ContainerRuntime,
+88
View File
@@ -0,0 +1,88 @@
import { createServer, type Server } from "node:http";
import express from "express";
import cookieParser from "cookie-parser";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js";
import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { registerDockerContainerRoutes } from "./container-routes.js";
import {
sshSessions,
pendingTOTPSessions,
executeDockerCommand,
} from "./session-manager.js";
import {
DOCKER_TIMESTAMP_RE,
getRequestUserId,
registerDockerSshRoutes,
} from "./routes.js";
import { startConsoleServer, closeConsoleServer } from "./console.js";
const PORT = 30007;
let httpServer: Server | null = null;
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
null;
export async function activate(ctx: PluginContext) {
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,
pendingTOTPSessions,
getRequestUserId,
executeDockerCommand,
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
});
const server = createServer(app);
await new Promise<void>((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",
});
}
// The console server is started here rather than on import so disabling the
// plugin frees port 30009 and re-enabling can take it again.
await startConsoleServer();
ctx.log.info(`Docker plugin listening on ${PORT} (console on 30009)`);
}
export async function deactivate() {
await closeConsoleServer();
if (httpServer) {
const server = httpServer;
httpServer = null;
await new Promise<void>((resolve) => server.close(() => resolve()));
}
authManagerInstance = null;
}
@@ -1,45 +1,45 @@
// 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";
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
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 "../../../src/backend/utils/logger.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} 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";
} 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";
} from "../../../../src/types/index.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../../../src/backend/utils/socks5-helper.js";
} from "../../../../src/backend/utils/socks5-helper.js";
import type {
LogEntry,
ConnectionStage,
} 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";
} 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 "../../../src/backend/hosts/ssh-dns.js";
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
import {
type SSHSession,
sshSessions,
@@ -307,7 +307,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
} else if (usesIssuedCertificate(resolvedCredentials.authType)) {
try {
const { getOPKSSHToken } =
await import("../../../src/backend/hosts/opkssh-auth.js");
await import("../../../../src/backend/hosts/opkssh-auth.js");
const token = await getOPKSSHToken(userId, hostId);
if (!token) {
@@ -327,7 +327,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
}
const { setupOPKSSHCertAuth } =
await import("../../../src/backend/hosts/opkssh-cert-auth.js");
await import("../../../../src/backend/hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(
config as import("ssh2").ConnectConfig,
client,
@@ -1,8 +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.
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { Client as SSHClient } from "ssh2";
import { logger } from "../../../src/backend/utils/logger.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import type { ContainerRuntime } from "./container-runtime.js";
const sshLogger = logger;
@@ -46,7 +46,7 @@ export async function register({
if (registerHostEditorTab) {
const { HostDockerTab } =
await import("../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
await import("../../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
registerHostEditorTab({
id: hostEditorTabId,
labelKey: "hosts.tabDocker",
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);