feat: harden plugin api for separate repos, signed updates and generic external login

This commit is contained in:
LukeGus committed 2026-09-29 18:11:59 -05:00
1 parent fb9c168b6b
commit 80944301dd
310 files changed
+6577 -4927

No files matched your search

+18 -41
View File
@@ -1,3 +1,12 @@
# Stage 0: every plugin's package.json and manifest.json and nothing else.
# npm ci needs each workspace package.json in place or it silently skips it,
# and a glob COPY flattens paths, so the tree is trimmed here instead. The
# deps layer below stays cached until one of these files changes.
FROM node:26-slim AS workspace-manifests
WORKDIR /src
COPY plugins ./plugins
RUN find plugins -mindepth 2 -maxdepth 2 ! -name package.json ! -name manifest.json -exec rm -rf {} +
# Stage 1: Install dependencies
FROM node:26-slim AS deps
WORKDIR /app
@@ -5,51 +14,14 @@ WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
# Every workspace package needs its package.json present before npm ci, or
# npm ci silently skips linking it and its dependencies never get installed
# (a glob COPY like plugins/*/package.json flattens into one file, so each
# plugin needs its own line).
COPY packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
COPY plugins/acme-ssl/package.json ./plugins/acme-ssl/package.json
COPY plugins/ai/package.json ./plugins/ai/package.json
COPY plugins/alerts/package.json ./plugins/alerts/package.json
COPY plugins/automations/package.json ./plugins/automations/package.json
COPY plugins/docker/package.json ./plugins/docker/package.json
COPY plugins/file-manager/package.json ./plugins/file-manager/package.json
COPY plugins/fleets/package.json ./plugins/fleets/package.json
COPY plugins/homepage/package.json ./plugins/homepage/package.json
COPY plugins/host-metrics/package.json ./plugins/host-metrics/package.json
COPY plugins/ldap/package.json ./plugins/ldap/package.json
COPY plugins/network-topology/package.json ./plugins/network-topology/package.json
COPY plugins/opkssh/package.json ./plugins/opkssh/package.json
COPY plugins/proxmox/package.json ./plugins/proxmox/package.json
COPY plugins/remote-desktop/package.json ./plugins/remote-desktop/package.json
COPY plugins/secret-sources/package.json ./plugins/secret-sources/package.json
COPY plugins/serial/package.json ./plugins/serial/package.json
COPY plugins/session-recording/package.json ./plugins/session-recording/package.json
COPY plugins/session-sharing/package.json ./plugins/session-sharing/package.json
COPY plugins/snippets/package.json ./plugins/snippets/package.json
COPY plugins/ssh-terminal/package.json ./plugins/ssh-terminal/package.json
COPY plugins/sso/package.json ./plugins/sso/package.json
COPY plugins/step-ca/package.json ./plugins/step-ca/package.json
COPY plugins/tailscale/package.json ./plugins/tailscale/package.json
COPY plugins/termix-identity/package.json ./plugins/termix-identity/package.json
COPY plugins/tmux-monitor/package.json ./plugins/tmux-monitor/package.json
COPY plugins/totp/package.json ./plugins/totp/package.json
COPY plugins/tunnels/package.json ./plugins/tunnels/package.json
COPY plugins/vault/package.json ./plugins/vault/package.json
COPY plugins/wake-on-lan/package.json ./plugins/wake-on-lan/package.json
COPY plugins/warpgate/package.json ./plugins/warpgate/package.json
COPY plugins/web-endpoint/package.json ./plugins/web-endpoint/package.json
COPY plugins/webauthn/package.json ./plugins/webauthn/package.json
COPY plugins/workspaces/package.json ./plugins/workspaces/package.json
COPY --from=workspace-manifests /src/plugins ./plugins
COPY .npmrc ./
COPY vendor ./vendor
COPY scripts/patch-guacamole-lite.cjs scripts/patch-ssh2-agent.cjs ./scripts/
COPY scripts/patch-ssh2-agent.cjs ./scripts/
RUN npm ci --ignore-scripts && \
node scripts/patch-guacamole-lite.cjs && \
node scripts/patch-ssh2-agent.cjs && \
npm cache clean --force
@@ -110,10 +82,15 @@ COPY packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
COPY .npmrc ./
COPY vendor ./vendor
COPY scripts/patch-guacamole-lite.cjs scripts/patch-ssh2-agent.cjs ./scripts/
COPY scripts/patch-ssh2-agent.cjs scripts/install-native-plugin-deps.cjs ./scripts/
# Plugin manifests only, for their nativeDependencies. Outside plugins/ so npm
# ci does not treat them as workspaces: everything else a plugin uses is
# already inside its bundle.
COPY --from=workspace-manifests /src/plugins ./plugin-manifests
RUN npm ci --omit=dev --ignore-scripts && \
node scripts/patch-guacamole-lite.cjs && \
node scripts/install-native-plugin-deps.cjs plugin-manifests && \
rm -rf plugin-manifests && \
node scripts/patch-ssh2-agent.cjs && \
rm -rf node_modules/better-sqlite3/prebuilds && \
npm run build-release --prefix node_modules/better-sqlite3 && \
+32 -10
View File
@@ -35,6 +35,7 @@ const IPC = {
DOWNLOAD: "local-transfer:download",
CANCEL: "local-transfer:cancel",
PROGRESS: "local-transfer:progress",
SET_API: "local-transfer:set-api",
};
const READ_CHUNK_BYTES = 1024 * 1024;
@@ -325,10 +326,19 @@ const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
// A compact JWT (base64url segments joined by dots); anything else is refused.
const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
// The file-manager plugin serves both routes under /plugin-api/file-manager
// on the main backend.
const FILE_MANAGER_API = "/plugin-api/file-manager";
const DEFAULT_LOCAL_FILE_MANAGER_BASE = `http://localhost:30001${FILE_MANAGER_API}`;
// The plugin that serves the two streaming routes tells us its /plugin-api/
// path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
// which keeps every transfer on the backend's plugin routes.
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/;
const LOCAL_BACKEND = "http://localhost:30001";
let registeredApiPath = null;
function setTransferApiPath(apiPath) {
if (typeof apiPath !== "string" || !API_PATH_PATTERN.test(apiPath)) {
throw new Error("Invalid transfer API path");
}
registeredApiPath = apiPath;
}
function normalizeHttpBase(candidate, what) {
let parsed;
@@ -346,10 +356,13 @@ function normalizeHttpBase(candidate, what) {
// Resolves where a transfer may go. Only the two file-manager streaming
// routes are reachable, and only on the embedded backend or the linked
// server; credentials come from the main process, not the caller.
function createTargetResolver({
localBaseUrl = DEFAULT_LOCAL_FILE_MANAGER_BASE,
getLinkedServer,
}) {
function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
const resolveApiPath = () => {
const resolved = apiPath ?? registeredApiPath;
if (!resolved)
throw new Error("Local file transfers are not available yet");
return resolved;
};
return function resolveTransferTarget({
origin,
route,
@@ -389,7 +402,10 @@ function createTargetResolver({
headers.Authorization = `Bearer ${authToken}`;
}
return {
url: `${normalizeHttpBase(localBaseUrl, "Local backend URL")}${routePath}`,
url: `${normalizeHttpBase(
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`,
"Local backend URL",
)}${routePath}`,
headers,
};
}
@@ -402,7 +418,7 @@ function createTargetResolver({
}
const base = normalizeHttpBase(linked.serverUrl, "Linked server URL");
if (linked.token) headers.Authorization = `Bearer ${linked.token}`;
return { url: `${base}${FILE_MANAGER_API}${routePath}`, headers };
return { url: `${base}${resolveApiPath()}${routePath}`, headers };
}
throw new Error(`Unknown transfer origin: ${String(origin)}`);
@@ -890,6 +906,11 @@ function createLocalFileHandlers({
};
return {
[IPC.SET_API]: wrap(async (_event, apiPath) => {
setTransferApiPath(apiPath);
return {};
}),
[IPC.HOME]: wrap(async () => ({
home: os.homedir(),
separator: path.sep,
@@ -1028,6 +1049,7 @@ module.exports = {
// exported for tests / reuse
createLocalFileHandlers,
createTargetResolver,
setTransferApiPath,
publishDownload,
defaultPublishFs,
assertWithinRoot,
+32 -5
View File
@@ -630,7 +630,14 @@ const isolatedWindows = createIsolatedWindows({
// ctx.desktop.openIsolatedWindow is the one caller today.
const BACKEND_REQUEST_HANDLERS = {
"open-isolated-window": (payload) => isolatedWindows.open(payload),
"launch-native-rdp": (payload) => launchNativeRdp(payload),
// The OS client for a protocol. Only RDP has one wired up.
"launch-external-client": (payload) =>
payload?.protocol === "rdp"
? launchNativeRdp(payload)
: {
success: false,
error: `No external client for ${payload?.protocol ?? "this protocol"}`,
},
"sync-proxy-config": (payload) => {
linkedServer.setLinkedServer(payload);
return { success: true };
@@ -1590,9 +1597,9 @@ ipcMain.handle("get-embedded-server-status", () => {
};
});
// OIDC System Browser Authentication (RFC 8252)
// External login through the system browser (RFC 8252)
ipcMain.handle(
"oidc-system-browser-auth",
"external-browser-login",
async (_event, authUrl, callbackPort) => {
const http = require("http");
@@ -1666,7 +1673,7 @@ ipcMain.handle(
// Timeout after 5 minutes
timeout = setTimeout(
() => {
fail(new Error("OIDC authentication timed out"));
fail(new Error("External login timed out"));
},
5 * 60 * 1000,
);
@@ -1868,8 +1875,28 @@ function getC2SRemoteBaseUrl() {
return serverUrl.replace(/\/$/, "");
}
// The socket path on the linked server that relays C2S streams. The plugin
// that owns the relay tells us where it is, so nothing here names it.
let c2sRelayPath = null;
const C2S_RELAY_PATH_PATTERN =
/^\/plugin-ws\/[a-z0-9][a-z0-9-]*\/[A-Za-z0-9/_-]+$/;
ipcMain.handle("set-c2s-relay-path", (_event, relayPath) => {
if (
typeof relayPath !== "string" ||
!C2S_RELAY_PATH_PATTERN.test(relayPath)
) {
return { success: false, error: "Invalid relay path" };
}
c2sRelayPath = relayPath;
return { success: true };
});
function getC2SRelayUrl() {
const relayHttpUrl = `${getC2SRemoteBaseUrl()}/plugin-ws/tunnels/c2s/stream`;
if (!c2sRelayPath) {
throw new Error("Client tunnels are not available yet. Try again shortly.");
}
const relayHttpUrl = `${getC2SRemoteBaseUrl()}${c2sRelayPath}`;
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
}
+8 -1
View File
@@ -53,6 +53,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
},
startC2SAutoStartTunnels: () =>
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
setC2SRelayPath: (relayPath) =>
ipcRenderer.invoke("set-c2s-relay-path", relayPath),
onCloseActiveTab: (callback) => {
const listener = () => callback();
@@ -72,8 +74,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
timeoutMs,
),
externalBrowserLogin: (authUrl, callbackPort) =>
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
// 2.8 name, read by older server login pages. Remove in 3.0.0.
oidcSystemBrowserAuth: (authUrl, callbackPort) =>
ipcRenderer.invoke("oidc-system-browser-auth", authUrl, callbackPort),
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
openExternalEditor: (fileData) =>
ipcRenderer.invoke("open-external-editor", fileData),
@@ -151,6 +156,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
open: (targetPath) => ipcRenderer.invoke("local-fs:open", targetPath),
},
localTransfer: {
setApiPath: (apiPath) =>
ipcRenderer.invoke("local-transfer:set-api", apiPath),
upload: (options) => ipcRenderer.invoke("local-transfer:upload", options),
download: (options) =>
ipcRenderer.invoke("local-transfer:download", options),
+15
View File
@@ -43,6 +43,21 @@ const pluginBoundary = {
ExportNamedDeclaration: (node) => check(node, node.source?.value),
ExportAllDeclaration: (node) => check(node, node.source.value),
ImportExpression: (node) => check(node, node.source.value),
// vi.mock("@/x") and require("../../x") name a module too.
CallExpression: (node) => {
const callee = node.callee;
const named =
(callee.type === "Identifier" && callee.name === "require") ||
(callee.type === "MemberExpression" &&
callee.object.type === "Identifier" &&
callee.object.name === "vi" &&
callee.property.type === "Identifier" &&
["mock", "doMock", "importActual", "unmock"].includes(
callee.property.name,
));
const first = node.arguments[0];
if (named && first?.type === "Literal") check(node, first.value);
},
};
},
},
-33
View File
@@ -23,46 +23,28 @@
],
"ignoreBinaries": ["powershell"],
"ignoreDependencies": [
"@codemirror/autocomplete",
"@codemirror/commands",
"@codemirror/search",
"@codemirror/theme-one-dark",
"@codemirror/view",
"@electron/notarize",
"@monaco-editor/react",
"@radix-ui/react-accordion",
"@radix-ui/react-alert-dialog",
"@radix-ui/react-checkbox",
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-label",
"@radix-ui/react-popover",
"@radix-ui/react-progress",
"@radix-ui/react-scroll-area",
"@radix-ui/react-select",
"@radix-ui/react-separator",
"@radix-ui/react-slider",
"@radix-ui/react-slot",
"@radix-ui/react-switch",
"@radix-ui/react-tabs",
"@radix-ui/react-tooltip",
"@types/better-sqlite3",
"@types/cookie-parser",
"@types/cors",
"@types/express",
"@types/guacamole-common-js",
"@types/js-yaml",
"@types/jsonwebtoken",
"@types/multer",
"@types/ssh2",
"@uiw/codemirror-extensions-langs",
"@uiw/codemirror-theme-github",
"@uiw/react-codemirror",
"@xterm/addon-clipboard",
"@xterm/addon-fit",
"@xterm/addon-unicode11",
"@xterm/addon-web-links",
"@xterm/xterm",
"axios",
"bcryptjs",
"better-sqlite3",
@@ -73,30 +55,15 @@
"cmdk",
"cookie-parser",
"cors",
"cytoscape",
"drizzle-orm",
"express",
"guacamole-lite",
"guacamole-common-js",
"js-yaml",
"jsonwebtoken",
"jszip",
"multer",
"nanoid",
"i18next-browser-languagedetector",
"lucide-react",
"motion",
"radix-ui",
"react-cytoscapejs",
"react-h5-audio-player",
"react-hook-form",
"react-icons",
"react-markdown",
"react-pdf",
"react-photo-view",
"react-syntax-highlighter",
"react-xtermjs",
"remark-gfm",
"socks",
"ssh2",
"undici",
+662 -423
View File
File diff suppressed because it is too large. Load diff
+4 -35
View File
@@ -16,12 +16,12 @@
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-guacamole-common-js.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-xterm-android-ime.cjs && node scripts/patch-ssh2-agent.cjs",
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/apply-plugin-patches.cjs",
"prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && npm run plugin:validate-manifest && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-plugin-locales.cjs && node scripts/check-test-locations.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && npm run plugin:validate-manifest && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-plugin-locales.cjs && node scripts/check-plugin-uses.cjs && node scripts/check-test-locations.cjs",
"lint:fix": "eslint --fix .",
"type-check": "tsc -b --force",
"type-check": "npm run build:sdk && tsc -b --force",
"test": "vitest run",
"verify:dialect": "tsx scripts/verify-dialects.mjs",
"test:watch": "vitest",
@@ -49,13 +49,12 @@
"schema:check": "node scripts/generate-dialect-schema.cjs --check",
"schema:migrations": "drizzle-kit generate --config=drizzle.config.sqlite.ts && drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts",
"plugin:validate-manifest": "node scripts/validate-plugin-manifest.cjs",
"build:sdk": "npm run build --workspace @termix/plugin-sdk",
"build:sdk": "npm run build --workspace @termix/plugin-sdk && node scripts/build-sdk-host.mjs",
"build:plugins": "node scripts/build-plugins.cjs",
"test:plugins": "node scripts/run-plugin-tests.cjs",
"test:all": "npm run test && npm run test:plugins"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.120.0",
"@tanstack/react-virtual": "^3.14.13",
"@types/compression": "^1.8.1",
"@types/semver": "^7.8.0",
@@ -70,9 +69,7 @@
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"guacamole-lite": "^1.2.0",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.2",
"motion": "^13.1.1",
"multer": "^2.3.0",
"mysql2": "^3.23.4",
@@ -88,9 +85,6 @@
"ws": "^8.21.3"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.1",
"@codemirror/search": "^6.7.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.12",
"@commitlint/cli": "^21.2.2",
@@ -103,24 +97,17 @@
"@fontsource/fira-code": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/source-code-pro": "^5.3.0",
"@monaco-editor/react": "^4.7.0",
"@peculiar/x509": "^2.1.0",
"@radix-ui/react-accordion": "^1.2.20",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slider": "^1.4.7",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
@@ -130,7 +117,6 @@
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/guacamole-common-js": "^1.5.5",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.2.0",
"@types/node": "^26.2.0",
@@ -140,18 +126,11 @@
"@types/ssh2": "^1.15.6",
"@types/supertest": "^7.2.1",
"@types/ws": "^8.18.1",
"@uiw/codemirror-extensions-langs": "^4.25.11",
"@uiw/codemirror-theme-github": "^4.25.11",
"@uiw/react-codemirror": "^4.25.11",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/ui": "^4.1.11",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -164,7 +143,6 @@
"eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.11.0",
"guacamole-common-js": "^1.5.0",
"husky": "^9.1.7",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
@@ -176,17 +154,8 @@
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-h5-audio-player": "^3.10.2",
"react-hook-form": "^7.84.0",
"react-i18next": "^17.0.14",
"react-icons": "^5.7.0",
"react-markdown": "^10.1.0",
"react-pdf": "^10.5.0",
"react-photo-view": "^1.2.7",
"react-syntax-highlighter": "^16.1.1",
"react-xtermjs": "^1.0.10",
"reflect-metadata": "^0.2.2",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.8",
"supertest": "^7.3.0",
"tailwind-merge": "^3.5.0",
+103
View File
@@ -1,5 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
import { staticUrlImports } from "../lib/static-url-imports.mjs";
@@ -48,11 +50,110 @@ export function externalRequireInterop(externals) {
};
}
const SDK_PATCHES = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"patches",
);
/**
* Patches a dependency before it is bundled, so the fix ships inside the
* plugin rather than depending on whoever ran npm install.
*
* package.json "termix": { "patches": ["xterm-android-ime"] } names patches
* the SDK ships for libraries several plugins share. Any .cjs file in the
* plugin's own patches/ folder runs too. Every patch must be safe to run
* twice.
*/
export function applyPatches(cwd, pluginId) {
const pkgPath = path.join(cwd, "package.json");
const pkg = fs.existsSync(pkgPath)
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
: {};
const scripts = [];
for (const name of pkg.termix?.patches ?? []) {
const file = path.join(SDK_PATCHES, `${name}.cjs`);
if (!/^[a-z0-9-]+$/.test(name) || !fs.existsSync(file)) {
throw new Error(`${pluginId}: the SDK has no patch named "${name}"`);
}
scripts.push(file);
}
const own = path.join(cwd, "patches");
if (fs.existsSync(own)) {
for (const file of fs.readdirSync(own).sort()) {
if (file.endsWith(".cjs")) scripts.push(path.join(own, file));
}
}
for (const file of scripts) {
const result = spawnSync(process.execPath, [file], {
cwd,
env: { ...process.env, TERMIX_PATCH_ROOT: cwd },
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(`${pluginId}: patch ${path.basename(file)} failed`);
}
}
}
const THEME_CSS = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"dist",
"host",
"theme.css",
);
/**
* Compiles the Tailwind classes the plugin's frontend uses into
* dist/frontend.css, against the theme core ships in the SDK. Core only
* compiles its own classes, so a class no core file happens to use would
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
* produced (a library's stylesheet the plugin imports).
*/
export async function buildTailwind(cwd, outDir) {
const { compile, optimize } = await import("@tailwindcss/node");
const { Scanner } = await import("@tailwindcss/oxide");
if (!fs.existsSync(THEME_CSS)) {
throw new Error("The SDK theme is missing. Run: npm run build:sdk");
}
const input = [
'@import "tailwindcss/theme.css" theme(reference);',
`@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`,
'@import "tailwindcss/utilities.css" layer(utilities);',
].join("\n");
const compiler = await compile(input, {
base: path.dirname(THEME_CSS),
onDependency: () => {},
});
const scanner = new Scanner({
sources: [
{
base: path.join(cwd, "src", "frontend"),
pattern: "**/*",
negated: false,
},
],
});
// Flattened and minified the way core's own CSS is, so it does not rely
// on native CSS nesting.
const css = optimize(compiler.build(scanner.scan()), {
minify: true,
}).code.trim();
if (!css) return;
const target = path.join(outDir, "frontend.css");
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
fs.writeFileSync(target, `${existing}${existing ? "\n" : ""}${css}\n`);
}
export async function build({ cwd }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
const outDir = path.join(cwd, "dist");
applyPatches(cwd, pluginId);
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
@@ -123,6 +224,8 @@ export async function build({ cwd }) {
});
}
if (frontendEntry) await buildTailwind(cwd, outDir);
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
+44 -4
View File
@@ -3,7 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { readManifest } from "../lib/plugin-dir.mjs";
import { LEGACY_TABLE_OWNERS as LEGACY_TABLES } from "../../dist/db.js";
import { findUnownedTableWrites } from "../../dist/ddl.js";
import { collectOwnedIndexes, findUnownedTableWrites } from "../../dist/ddl.js";
/**
* The manifest rules live in src/manifest.ts, which the server uses too, so
@@ -39,6 +39,7 @@ export async function validate({ cwd }) {
problems.push(...validateMigrations(cwd, raw.id ?? path.basename(cwd)));
problems.push(...validateNativeDependencies(cwd, raw));
problems.push(...validatePackage(cwd, raw));
if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`);
@@ -48,6 +49,36 @@ export async function validate({ cwd }) {
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
}
/**
* package.json has to agree with the manifest: the same version, and a real
* SDK range rather than "*", so a plugin built against a newer SDK says so.
*/
function validatePackage(cwd, raw) {
const problems = [];
const pkgPath = path.join(cwd, "package.json");
if (!fs.existsSync(pkgPath)) return problems;
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (pkg.version && raw.version && pkg.version !== raw.version) {
problems.push(
`package.json version ${pkg.version} does not match manifest version ${raw.version}`,
);
}
const sdkRange =
pkg.peerDependencies?.["@termix/plugin-sdk"] ??
pkg.dependencies?.["@termix/plugin-sdk"] ??
pkg.devDependencies?.["@termix/plugin-sdk"];
if (!sdkRange) {
problems.push("package.json does not depend on @termix/plugin-sdk");
} else if (sdkRange === "*" || sdkRange === "latest") {
problems.push(
`@termix/plugin-sdk is "${sdkRange}"; pin a range such as "^1.0.0"`,
);
}
return problems;
}
/**
* A native dependency has to actually be a real dependency of the plugin, or
* the build's external declaration points at nothing node_modules can
@@ -106,16 +137,25 @@ function validateMigrations(cwd, pluginId) {
.filter((file) => file.endsWith(".sql"))
.sort();
byDialect.set(dialect, files);
const sqls = files.map((file) =>
fs.readFileSync(path.join(dir, file), "utf8"),
);
const indexes = collectOwnedIndexes(pluginId, sqls, legacy);
for (const file of files) {
for (const [index, file] of files.entries()) {
if (!/^\d{4}_[a-z0-9_]+\.sql$/.test(file)) {
problems.push(
`migrations/${dialect}/${file} must be named NNNN_name.sql, lower snake_case`,
);
}
const sql = fs.readFileSync(path.join(dir, file), "utf8");
for (const problem of findUnownedTableWrites(pluginId, sql, legacy)) {
const sql = sqls[index];
for (const problem of findUnownedTableWrites(
pluginId,
sql,
legacy,
indexes,
)) {
problems.push(`migrations/${dialect}/${file} ${problem}`);
}
}
+4 -1
View File
@@ -6,7 +6,8 @@
*/
import process from "node:process";
import { build } from "./commands/build.mjs";
import { applyPatches, build } from "./commands/build.mjs";
import { readManifest } from "./lib/plugin-dir.mjs";
import { validate } from "./commands/validate.mjs";
import { test } from "./commands/test.mjs";
import { pack } from "./commands/pack.mjs";
@@ -15,6 +16,7 @@ import { migrations } from "./commands/migrations.mjs";
const COMMANDS = {
build,
patch: async ({ cwd }) => applyPatches(cwd, readManifest(cwd).id),
validate,
test,
pack,
@@ -32,6 +34,7 @@ if (!command || command === "--help" || command === "-h") {
"Usage: termix-plugin <command>",
"",
" build Bundle the plugin into dist/",
" patch Apply the plugin's dependency patches (build does this too)",
" validate Check manifest.json and the files it names",
" test Run the plugin's vitest suite",
" pack Write <id>-<version>.tmxplug of the built plugin [--out dir]",
+4 -3
View File
@@ -6,6 +6,10 @@
* a second ssh2 would mean a second set of native bindings. A plugin may
* depend on anything outside these lists, and that does get bundled.
*
* A plugin lists the ones it uses as peerDependencies. Only add a package here
* when core itself ships it; a library one plugin needs belongs in that
* plugin's own dependencies, so it can update without a core release.
*
* Node builtins are external automatically through platform: "node".
*/
@@ -18,9 +22,6 @@ export const BACKEND_EXTERNALS = [
"multer",
"cookie-parser",
"axios",
"jszip",
"guacamole-lite",
"@anthropic-ai/sdk",
"drizzle-orm",
"drizzle-orm/*",
// Native image processing, loaded on first use by the terminal's image upload.
@@ -1,12 +1,20 @@
const fs = require("fs");
const path = require("path");
const packageRoot = path.join(
__dirname,
"..",
"node_modules",
"guacamole-common-js",
);
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
// the package resolves the way the plugin's bundle will resolve it.
function packageDir(name) {
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
// Walked by hand: a package whose exports map hides package.json cannot be
// found with require.resolve.
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
const candidate = path.join(dir, "node_modules", name);
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
}
}
const packageRoot = path.join(packageDir("guacamole-common-js"));
const bundlePaths = [
path.join(packageRoot, "dist", "esm", "guacamole-common.js"),
@@ -1,14 +1,20 @@
const fs = require("node:fs");
const path = require("node:path");
const xtermDir = path.join(
__dirname,
"..",
"node_modules",
"@xterm",
"xterm",
"lib",
);
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
// the package resolves the way the plugin's bundle will resolve it.
function packageDir(name) {
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
// Walked by hand: a package whose exports map hides package.json cannot be
// found with require.resolve.
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
const candidate = path.join(dir, "node_modules", name);
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
}
}
const xtermDir = path.join(packageDir("@xterm/xterm"), "lib");
// Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
+13
View File
@@ -0,0 +1,13 @@
/**
* Import forms `termix-plugin build` understands beyond plain modules. Picked
* up through tsconfig.plugin.json.
*/
/** A script copied next to the bundle; the import is its URL. */
declare module "*?url" {
const url: string;
export default url;
}
/** A stylesheet imported for its side effect; the build emits frontend.css. */
declare module "*.css";
+28 -4
View File
@@ -37,6 +37,10 @@
"types": "./dist/frontend.d.ts",
"default": "./dist/frontend.js"
},
"./desktop": {
"types": "./dist/desktop.d.ts",
"default": "./dist/desktop.js"
},
"./manifest": {
"types": "./dist/manifest.d.ts",
"default": "./dist/manifest.js"
@@ -70,29 +74,46 @@
"default": "./dist/ssh-certs.js"
},
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
"./schema/manifest.schema.json": "./schema/manifest.schema.json"
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
"./ui": {
"types": "./dist/host/ui.d.ts",
"default": "./dist/host/ui.js"
},
"./testing-host": {
"default": "./dist/host/testing-host.js"
},
"./theme.css": "./dist/host/theme.css"
},
"files": [
"dist",
"cli",
"schema",
"tsconfig.plugin.json",
"README.md"
"README.md",
"client.d.ts"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@tailwindcss/node": "^4.2.4",
"@tailwindcss/oxide": "^4.2.4",
"esbuild": "^0.28.1",
"semver": "^7.6.3",
"tailwindcss": "^4.2.4",
"tar": "^7.5.22"
},
"peerDependencies": {
"drizzle-orm": "*",
"ssh2": "*",
"@testing-library/react": "^16.3.3",
"better-sqlite3": "*",
"drizzle-orm": "*",
"i18next": "^26.4.2",
"react": "*",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.14",
"sonner": "^2.0.8",
"ssh2": "*",
"vitest": "*"
},
"peerDependenciesMeta": {
@@ -110,6 +131,9 @@
},
"vitest": {
"optional": true
},
"@testing-library/react": {
"optional": true
}
},
"publishConfig": {
+52 -12
View File
@@ -82,12 +82,12 @@
"termix": {
"type": "string",
"minLength": 1,
"description": "Display string only. Never enforced."
"description": "Semver range of Termix releases the plugin runs on, e.g. \">=2.9.0\"."
},
"api": {
"type": "string",
"pattern": "^[0-9]+$",
"description": "Plugin SDK major version. The real compatibility gate."
"minLength": 1,
"description": "Semver range of the plugin API it needs, e.g. \"1\" or \"^1.2\"."
}
}
},
@@ -115,6 +115,8 @@
"notify:send",
"audit:read",
"desktop:window",
"users:impersonate",
"db:core-refs",
"hosts:read",
"db:own",
"kv:own",
@@ -290,7 +292,9 @@
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{0,31}$",
"not": { "enum": ["ssh"] }
"not": {
"enum": ["ssh"]
}
},
"credentialFields": {
"type": "array",
@@ -311,7 +315,9 @@
]
}
},
"secret": { "type": "boolean" }
"secret": {
"type": "boolean"
}
}
}
},
@@ -323,7 +329,9 @@
"hostLoginFallback": {
"type": "array",
"uniqueItems": true,
"items": { "enum": ["username", "password"] }
"items": {
"enum": ["username", "password"]
}
}
}
}
@@ -352,17 +360,28 @@
"type": "object",
"propertyNames": {
"pattern": "^[a-zA-Z][a-zA-Z0-9]{0,31}$",
"not": { "const": "type" }
"not": {
"const": "type"
}
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": { "enum": ["string", "boolean"] },
"required": { "type": "boolean" },
"pattern": { "type": "string" },
"maxLength": { "type": "integer", "minimum": 1 },
"type": {
"enum": ["string", "boolean"]
},
"required": {
"type": "boolean"
},
"pattern": {
"type": "string"
},
"maxLength": {
"type": "integer",
"minimum": 1
},
"syncEntity": {
"type": "string",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
@@ -894,6 +913,15 @@
"ownerOnly": {
"type": "boolean",
"description": "Only the host's owner may change it."
},
"secretKeys": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
},
"description": "JSON fields only: keys inside the stored object that hold secrets."
}
}
}
@@ -962,7 +990,9 @@
"type": "string",
"pattern": "^(/[A-Za-z0-9_.-]+)+$"
},
"status": { "enum": [307, 308] }
"status": {
"enum": [307, 308]
}
}
}
}
@@ -1041,6 +1071,16 @@
}
}
}
},
"uses": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"description": "Other plugins' action, slot and extension point ids this plugin calls, fills or extends.",
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
}
}
}
},
+35 -12
View File
@@ -96,8 +96,8 @@ export interface PluginFiles {
* to query through `db`. Both require db:own.
*
* Scoped by name: every table carries the p_<id>_ prefix, and `refs` exposes
* users, ssh_data, roles and user_roles read-only so a plugin can join against
* them without being able to write them. In-process code could reach around
* users, ssh_data, roles and user_roles (behind db:core-refs) so a plugin can
* join against them. In-process code could reach around
* all of this. The capability, the prefix, lint and review are the contract,
* not a sandbox.
*/
@@ -106,7 +106,10 @@ export interface PluginDatabase {
define: <T = unknown>(definition: PluginTableDefinition) => Promise<T>;
/** Drizzle handle, scoped to this plugin's tables. */
client: <T = unknown>() => Promise<T>;
/** Read-only references to the core tables a plugin may point at. */
/**
* Core tables a plugin may join against: users, hosts, roles, userRoles.
* Needs db:core-refs. Treat them as read-only.
*/
refs: <T = unknown>() => Promise<T>;
/**
* Flushes writes to disk. Call it after every write: on SQLite the database
@@ -235,6 +238,11 @@ export interface PluginSync {
registerEntity: (entity: SyncEntityRegistration) => void;
}
/**
* A plain key/value hand-off between plugins. `provide` and `revoke` only take
* keys under the plugin's own id (`<pluginId>.something`); anyone may
* `consume`.
*/
export interface PluginRegistry {
provide: <T>(key: string, value: T) => void;
consume: <T>(key: string) => T | undefined;
@@ -252,7 +260,11 @@ export interface PluginServices {
implementation: T,
options?: { name?: string },
) => void;
/** `provider` picks a named provider; omitted, the unnamed one. */
/**
* The service must be listed in the manifest's `requires` (or be one this
* plugin provides). `provider` picks a named provider; omitted, the unnamed
* one. A `userId` other than the acting user needs users:impersonate.
*/
get: <T extends object>(
service: string,
options?: { userId?: string; provider?: string },
@@ -1340,6 +1352,12 @@ export interface PluginLoginInstance {
* route ("oidc", "github", "google", "ldap"). Leave unset otherwise.
*/
type?: string;
/**
* Redirect methods: the login page starts this instance without a click,
* for a server that signs everyone in through one provider. The first
* instance that asks wins.
*/
autoStart?: boolean;
}
export interface PluginLoginMethod {
@@ -1468,7 +1486,13 @@ export interface PluginOpenIsolatedWindowRequest {
* Electron only: rejects when the server is not running embedded in the
* desktop app. Needs desktop:window.
*/
export interface PluginNativeRdpRequest {
/**
* A host to open in the operating system's own client for a protocol. The
* desktop app supports "rdp" (mstsc on Windows) today; any other protocol is
* refused.
*/
export interface PluginExternalClientRequest {
protocol: string;
host: string;
port?: number;
username?: string;
@@ -1480,12 +1504,11 @@ export interface PluginDesktop {
request: PluginOpenIsolatedWindowRequest,
) => Promise<{ success: true }>;
/**
* Opens the operating system's own RDP client (mstsc on Windows) for a
* host. The password is never passed; the client asks for it. Windows
* desktop app only.
* Opens the operating system's own client for a protocol. The password is
* never passed; the client asks for it. Desktop app only.
*/
launchNativeRdp: (
request: PluginNativeRdpRequest,
launchExternalClient: (
request: PluginExternalClientRequest,
) => Promise<{ success: boolean; error?: string }>;
/** Whether the server runs embedded in the desktop app, so the calls above can work. */
available: () => boolean;
@@ -1942,8 +1965,8 @@ export interface PluginContext {
* no request behind it. Always audited, and every core API inside still
* applies that user's RBAC.
*
* This is the only way a plugin can name a user. Nothing else trusts a user
* id that came from plugin code.
* Needs users:impersonate. That and a `userId` option on services.get or
* secrets.getShared are the only ways a plugin can name a user.
*/
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
+2
View File
@@ -44,6 +44,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("ssh:connect", "high"),
entry("process:spawn", "high"),
entry("users:write", "high"),
entry("users:impersonate", "high"),
entry("db:core-refs", "high"),
entry("auth:provide", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
+171 -6
View File
@@ -429,7 +429,35 @@ const REFUSED_STATEMENTS = new Set([
/** What a migration may CREATE, ALTER or DROP. Anything else is refused. */
const TABLE_OBJECTS = new Set(["TABLE", "VIEW"]);
const HARMLESS_OBJECTS = new Set(["SEQUENCE", "TYPE"]);
/** Allowed, but only under the plugin's own prefix. */
const PREFIXED_OBJECTS = new Set(["SEQUENCE", "TYPE"]);
/**
* Functions that read files, change server settings or run a string as SQL.
* A SELECT calling one of them could do anything, so it is refused.
*/
const REFUSED_FUNCTIONS = new Set([
"SET_CONFIG",
"PG_READ_FILE",
"PG_READ_BINARY_FILE",
"PG_LS_DIR",
"PG_STAT_FILE",
"PG_FILE_WRITE",
"LO_IMPORT",
"LO_EXPORT",
"LO_FROM_BYTEA",
"DBLINK",
"DBLINK_EXEC",
"QUERY_TO_XML",
"QUERY_TO_XML_AND_XMLSCHEMA",
"PG_TERMINATE_BACKEND",
"PG_CANCEL_BACKEND",
"PG_RELOAD_CONF",
"LOAD_FILE",
"LOAD_EXTENSION",
"SYS_EXEC",
"SYS_EVAL",
]);
const CREATE_MODIFIERS = [
"OR",
@@ -460,9 +488,27 @@ export function findUnownedTableWrites(
pluginId: string,
sql: string,
ownedLegacy: ReadonlySet<string> = new Set(),
ownedIndexes: ReadonlySet<string> = new Set(),
): string[] {
const prefix = tablePrefix(pluginId);
const problems: string[] = [];
const indexes = new Set([
...ownedIndexes,
...collectOwnedIndexes(pluginId, [sql], ownedLegacy),
]);
// The splitter and tokenizer follow standard quoting only. MySQL backslash
// escapes, E'' strings and Postgres dollar quoting would let a string end
// somewhere else for the engine than for this check, so none are allowed.
if (sql.includes("\\")) {
problems.push("backslashes are not allowed in a plugin migration");
}
if (/\$[A-Za-z_0-9]*\$/.test(sql)) {
problems.push(
"dollar-quoted strings are not allowed in a plugin migration",
);
}
if (problems.length > 0) return problems;
for (const statement of splitStatements(sql)) {
const tokens = tokenize(statement);
@@ -507,16 +553,47 @@ export function findUnownedTableWrites(
continue;
}
if (first !== "CREATE" && find("CASCADE") > 0) {
problems.push("CASCADE is not allowed in a plugin migration");
continue;
}
if (first === "CREATE" || first === "ALTER" || first === "DROP") {
let at = skip(1, CREATE_MODIFIERS);
const object = upper(at);
if (object === "INDEX") {
// The index name is free; the table after ON is what it writes to.
const on = find("ON", at);
if (on > 0) table(skip(on + 1, ["ONLY"]));
if (on > 0) {
table(skip(on + 1, ["ONLY"]));
} else if (first === "CREATE") {
problems.push("CREATE INDEX without ON is not allowed");
} else {
// DROP INDEX name: only an index this plugin created.
const nameAt = skip(at + 1, ["IF", "EXISTS", "CONCURRENTLY"]);
const name = tokens[nameAt];
if (
!name ||
name.kind === "punct" ||
tokens[nameAt + 1]?.value === "." ||
(!name.value.startsWith(prefix) && !indexes.has(name.value))
) {
problems.push(
`${first} INDEX "${name?.value ?? ""}" is not an index this plugin created`,
);
}
}
continue;
}
if (PREFIXED_OBJECTS.has(object)) {
const name = tokens[skip(at + 1, ["IF", "NOT", "EXISTS"])];
if (!name || !name.value.startsWith(prefix)) {
problems.push(
`${first} ${object} "${name?.value ?? ""}" is not prefixed "${prefix}"`,
);
}
continue;
}
if (HARMLESS_OBJECTS.has(object)) continue;
if (!TABLE_OBJECTS.has(object)) {
problems.push(
`${first} ${object || tokens[at]?.value || ""} is not allowed in a plugin migration`.trim(),
@@ -565,6 +642,14 @@ export function findUnownedTableWrites(
}
if (first === "UPDATE") {
// MySQL writes every table before SET: UPDATE a JOIN b ... SET b.x = 1.
const set = find("SET");
const end = set > 0 ? set : tokens.length;
for (let at = 1; at < end; at++) {
if (upper(at) === "JOIN" || tokens[at].value === ",") {
table(at + 1);
}
}
table(
skip(1, [
"OR",
@@ -582,12 +667,51 @@ export function findUnownedTableWrites(
if (first === "DELETE") {
const from = find("FROM");
if (from > 0) table(skip(from + 1, ["ONLY"]));
else problems.push("DELETE without FROM is not allowed");
if (from < 0) {
problems.push("DELETE without FROM is not allowed");
continue;
}
// MySQL: DELETE a, b FROM ... deletes from a and b.
let at = skip(1, ["LOW_PRIORITY", "QUICK", "IGNORE"]);
if (at < from) {
while (at < from) {
at = table(at);
if (tokens[at]?.value !== ",") break;
at++;
}
continue;
}
at = table(skip(from + 1, ["ONLY"]));
// DELETE FROM a, b USING ... deletes from both.
while (tokens[at]?.value === ",") at = table(at + 1);
continue;
}
if (first !== "SELECT" && first !== "COMMENT") {
if (first === "SELECT") {
// SELECT ... INTO creates a table on Postgres and writes a file on
// MySQL.
if (find("INTO") > 0) {
problems.push("SELECT INTO is not allowed in a plugin migration");
continue;
}
for (let at = 0; at < tokens.length; at++) {
if (REFUSED_FUNCTIONS.has(upper(at)) && tokens[at + 1]?.value === "(") {
problems.push(
`${tokens[at].value}() is not allowed in a plugin migration`,
);
}
}
// setval can break a core table's ids, so only the plugin's own.
for (const match of statement.matchAll(/setval\s*\(\s*([^,]*)/gi)) {
const target = /'([^']*)'/.exec(match[1])?.[1];
if (!target || !target.startsWith(prefix)) {
problems.push("setval() may only target this plugin's own sequences");
}
}
continue;
}
if (first !== "COMMENT") {
problems.push(
`"${tokens[0].value}" statements are not allowed in a plugin migration`,
);
@@ -596,3 +720,44 @@ export function findUnownedTableWrites(
return problems;
}
/**
* Names of the indexes a plugin's migrations create on its own tables, so a
* later DROP INDEX with no ON clause can be checked against them.
*/
export function collectOwnedIndexes(
pluginId: string,
sqls: readonly string[],
ownedLegacy: ReadonlySet<string> = new Set(),
): Set<string> {
const prefix = tablePrefix(pluginId);
const owned = new Set<string>();
for (const sql of sqls) {
for (const statement of splitStatements(sql)) {
const tokens = tokenize(statement);
const upper = (at: number) =>
tokens[at]?.kind === "word" ? tokens[at].value.toUpperCase() : "";
if (upper(0) !== "CREATE") continue;
let at = 1;
while (CREATE_MODIFIERS.includes(upper(at))) at++;
if (upper(at) !== "INDEX") continue;
at++;
while (["IF", "NOT", "EXISTS"].includes(upper(at))) at++;
const name = tokens[at];
let on = at;
while (on < tokens.length && upper(on) !== "ON") on++;
if (upper(on + 1) === "ONLY") on++;
const target = tokens[on + 1];
if (
name &&
name.kind !== "punct" &&
target &&
tokens[on + 2]?.value !== "." &&
(target.value.startsWith(prefix) || ownedLegacy.has(target.value))
) {
owned.add(name.value);
}
}
}
return owned;
}
+394
View File
@@ -0,0 +1,394 @@
/**
* The desktop app's bridge (window.electronAPI), typed for plugins. It exists
* only inside the Termix desktop app; check isElectron before calling it.
*/
export interface ServerConfig {
serverUrl?: string;
allowInvalidCertificate?: boolean;
[key: string]: unknown;
}
export interface ConnectionTestResult {
success: boolean;
error?: string;
[key: string]: unknown;
}
export interface DialogOptions {
title?: string;
defaultPath?: string;
buttonLabel?: string;
filters?: Array<{ name: string; extensions: string[] }>;
properties?: string[];
[key: string]: unknown;
}
export interface DialogResult {
canceled: boolean;
filePath?: string;
filePaths?: string[];
[key: string]: unknown;
}
export interface LocalDirectoryResult {
success: boolean;
path: string;
parent?: string;
entries: LocalFileEntry[];
error?: string;
}
export interface LocalCollectedFile {
path: string;
name: string;
relativePath: string;
size: number;
created?: string;
modified: string;
}
export type LocalPathMutationResult =
| ({ success: true } & Partial<LocalFileEntry>)
| { success: false; error?: string };
export interface ElectronAPI {
getAppVersion: () => Promise<string>;
setZoomFactor?: (factor: number) => void;
getPlatform: () => Promise<string>;
getSetting?: (key: string) => Promise<string | null | undefined>;
setSetting?: (key: string, value: string) => Promise<void>;
getServerConfig: () => Promise<ServerConfig>;
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
testServerConnection: (
serverUrl: string,
allowInvalidCertificate?: boolean,
) => Promise<ConnectionTestResult>;
getC2STunnelConfig: () => Promise<unknown[]>;
saveC2STunnelConfig: (
config: unknown[],
) => Promise<{ success: boolean; error?: string }>;
checkLocalPortAvailable: (
host: string,
port: number,
) => Promise<{ available: boolean; error?: string }>;
getC2STunnelPresetDefaultName: () => Promise<string>;
startC2STunnel: (
tunnel: unknown,
index: number,
authToken?: string,
) => Promise<{
success: boolean;
tunnelName?: string;
error?: string;
code?: string;
}>;
testC2STunnel: (
tunnel: unknown,
index: number,
authToken?: string,
) => Promise<{
success: boolean;
message?: string;
error?: string;
code?: string;
}>;
stopC2STunnel: (
tunnelName: string,
) => Promise<{ success: boolean; error?: string }>;
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
onC2STunnelStatuses?: (
callback: (statuses: Record<string, unknown>) => void,
) => () => void;
startC2SAutoStartTunnels: () => Promise<{
success: boolean;
started: number;
errors: string[];
}>;
/** Where the linked server relays client tunnel streams, a /plugin-ws/ path. */
setC2SRelayPath?: (
relayPath: string,
) => Promise<{ success: boolean; error?: string }>;
onCloseActiveTab?: (callback: () => void) => () => void;
clearSessionCookies: () => Promise<void>;
getSessionCookie: (
name: string,
targetUrl?: string,
) => Promise<string | null>;
waitForSessionCookie: (
name: string,
targetUrl?: string,
previousValue?: string | null,
timeoutMs?: number,
) => Promise<{ success: boolean; value?: string; error?: string }>;
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
getLocalHomeDirectory?: () => Promise<string>;
listLocalDirectory?: (path?: string) => Promise<LocalDirectoryResult>;
statLocalPaths?: (
paths: string[],
) => Promise<
Array<
| (LocalFileEntry & { success: true })
| { success: false; path: string; error?: string }
>
>;
collectLocalFiles?: (paths: string[]) => Promise<{
success: boolean;
files: LocalCollectedFile[];
truncated?: boolean;
error?: string;
}>;
createLocalFolder?: (
parentPath: string,
folderName: string,
) => Promise<LocalPathMutationResult>;
renameLocalPath?: (
path: string,
newName: string,
) => Promise<LocalPathMutationResult>;
trashLocalPath?: (path: string) => Promise<LocalPathMutationResult>;
chmodLocalPath?: (
path: string,
permissions: string,
) => Promise<LocalPathMutationResult>;
openExternalEditor: (fileData: {
fileName: string;
content: string;
encoding?: "utf8" | "base64";
editorPath?: string | null;
}) => Promise<{
success: boolean;
editId?: string;
path?: string;
error?: string;
}>;
closeExternalEditor: (editId: string) => Promise<{
success: boolean;
error?: string;
}>;
onExternalEditorSaved?: (
callback: (payload: {
editId: string;
content: string;
encoding: "utf8";
path: string;
}) => void,
) => () => void;
onUpdateAvailable: (callback: () => void) => void;
onUpdateDownloaded: (callback: () => void) => void;
removeAllListeners: (channel: string) => void;
isElectron: boolean;
isDev: boolean;
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
createTempFile: (fileData: {
fileName: string;
content: string;
encoding?: "base64" | "utf8";
}) => Promise<{
success: boolean;
tempId?: string;
path?: string;
error?: string;
}>;
createTempFolder: (folderData: {
folderName: string;
files: Array<{
relativePath: string;
content: string;
encoding?: "base64" | "utf8";
}>;
}) => Promise<{
success: boolean;
tempId?: string;
path?: string;
error?: string;
}>;
startDragToDesktop: (dragData: {
tempId: string;
fileName: string;
}) => Promise<{
success: boolean;
error?: string;
}>;
cleanupTempFile: (tempId: string) => Promise<{
success: boolean;
error?: string;
}>;
startLocalTerminal(dimensions: {
cols: number;
rows: number;
shell?: "default" | "wsl";
}): Promise<{ sessionId: string; shell: string }>;
readyLocalTerminal(sessionId: string): Promise<boolean>;
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
resizeLocalTerminal(
sessionId: string,
cols: number,
rows: number,
): Promise<boolean>;
closeLocalTerminal(sessionId: string): Promise<boolean>;
onLocalTerminalData(
sessionId: string,
callback: (data: string) => void,
): () => void;
onLocalTerminalExit(
sessionId: string,
callback: (exitCode: number) => void,
): () => void;
/** Local disk browsing for the dual-pane file manager (desktop only). */
localFs?: {
home: () => Promise<LocalFsResult<LocalFsHomeInfo>>;
list: (dirPath: string) => Promise<LocalFsResult<LocalDirectoryListing>>;
mkdir: (
parentPath: string,
name: string,
) => Promise<LocalFsResult<{ path: string }>>;
createFile: (
parentPath: string,
name: string,
) => Promise<LocalFsResult<{ path: string }>>;
rename: (
oldPath: string,
newName: string,
) => Promise<LocalFsResult<{ path: string }>>;
trash: (paths: string[]) => Promise<LocalFsResult<LocalTrashResult>>;
ensureDir: (
dirPath: string,
rootPath?: string,
) => Promise<LocalFsResult<{ path: string }>>;
exists: (paths: string[]) => Promise<LocalFsResult<{ existing: string[] }>>;
walk: (paths: string[]) => Promise<LocalFsResult<LocalWalkResult>>;
reveal: (targetPath: string) => Promise<LocalFsResult<unknown>>;
open: (targetPath: string) => Promise<LocalFsResult<unknown>>;
};
/** Streamed local<->remote transfers driven by the main process. */
localTransfer?: {
/** The /plugin-api/<id> path that serves the streaming routes. */
setApiPath?: (apiPath: string) => Promise<LocalFsResult<object>>;
upload: (
options: LocalUploadRequest,
) => Promise<LocalFsResult<{ bytes: number }>>;
download: (
options: LocalDownloadRequest,
) => Promise<LocalFsResult<{ path: string }>>;
cancel: (
transferId: string,
) => Promise<LocalFsResult<{ cancelled: boolean }>>;
onProgress: (
callback: (payload: LocalTransferProgress) => void,
) => () => void;
};
}
export type LocalFsResult<T> =
({ success: true } & T) | { success: false; error: string; code?: string };
export interface LocalFsHomeInfo {
home: string;
separator: string;
platform: string;
}
export interface LocalFileEntry {
name: string;
path: string;
type: "file" | "directory" | "link" | "other";
created?: string;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
size: number;
modifiedTimestamp?: number;
linkTarget?: string;
hidden: boolean;
}
export interface LocalDirectoryListing {
path: string;
parent: string | null;
entries: LocalFileEntry[];
}
export interface LocalTrashResult {
trashed: number;
failed: Array<{ path: string; error: string }>;
}
export interface LocalWalkFile {
localPath: string;
/** Path relative to the drop root; "/"-separated and includes the root's own name. */
relativePath: string;
size: number;
}
export interface LocalWalkResult {
files: LocalWalkFile[];
emptyDirs: string[];
totalBytes: number;
}
/**
* Which Termix backend a transfer talks to. The main process resolves the
* actual URL and credentials for the origin; the renderer never supplies them.
*/
export type LocalTransferOrigin = "local" | "remote";
export interface LocalUploadRequest {
transferId: string;
origin: LocalTransferOrigin;
fields: Record<string, string>;
localPath: string;
fileName: string;
deviceId?: string;
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
authToken?: string;
}
export interface LocalDownloadRequest {
transferId: string;
origin: LocalTransferOrigin;
body: Record<string, unknown>;
destPath: string;
/** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */
rootPath: string;
expectedSize?: number;
/** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */
overwrite?: boolean;
deviceId?: string;
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
authToken?: string;
}
export interface LocalTransferProgress {
transferId: string;
transferred: number;
total?: number;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
IS_ELECTRON: boolean;
electronClipboard?: {
writeText(text: string): Promise<boolean>;
readText(): Promise<string>;
};
}
}
+43 -5
View File
@@ -155,9 +155,6 @@ export interface PluginTabRecord {
instanceId?: string;
/** A backend session a session tab should reattach to. */
restoredSessionId?: string | null;
/** A live shared session this tab joins instead of connecting its own. */
joinSharedSessionId?: string | null;
joinShareId?: string | null;
[key: string]: unknown;
}
@@ -192,6 +189,11 @@ export interface ShellApi {
options?: OpenTabOptions,
) => void;
openSingletonTab: (type: string, options?: OpenTabOptions) => void;
/**
* Connects to a host the way clicking it in the host list does: its
* default connection, or `type` when given.
*/
connectHost: (host: PluginHostRecord, type?: string) => void;
closeTab: (tabId: string) => void;
renameTab: (tabId: string, label: string) => void;
/** Saves a quick-connect tab's host as a real host. Absent in some shells. */
@@ -212,7 +214,7 @@ export interface ShellApi {
export interface TabsApi extends Pick<
ShellApi,
"openTab" | "openSingletonTab" | "closeTab" | "openRailView"
"openTab" | "openSingletonTab" | "connectHost" | "closeTab" | "openRailView"
> {
/** The current tabs and split layout, or null before the shell mounts. */
getLayout: () => ShellLayout | null;
@@ -1085,6 +1087,15 @@ export interface PluginCoreApi {
listHosts: (pluginId: string) => Promise<PluginHostRecord[]>;
/** The user's stored credentials, without their secrets. */
listCredentials: () => Promise<PluginCredentialSummary[]>;
/** Tells the shell hosts were added or changed, so lists reload. */
notifyHostsChanged: () => void;
/** How the user wants host status shown: the brand accent, or green/red. */
getHostStatusColorScheme: () => "accent" | "status";
/**
* The token the desktop app's embedded backend accepts, for work the
* desktop main process does on the renderer's behalf. Null in a browser.
*/
getLocalAuthToken: () => string | null;
}
/** A stored credential as a picker needs it. Secrets never leave core. */
@@ -1114,8 +1125,17 @@ export interface HostStatusInfo {
let host: PluginHostBridge | null = null;
/** Called once by core. Not part of the plugin API. */
/**
* Called once by core, before any plugin loads. Not part of the plugin API:
* once set it cannot be swapped, so a plugin cannot replace the bridge every
* other plugin talks through.
*/
export function __setPluginHost(bridge: PluginHostBridge | null): void {
if (host && bridge !== host) {
throw new Error(
"@termix/plugin-sdk/frontend: the plugin host is already set",
);
}
host = bridge;
}
@@ -1211,6 +1231,21 @@ export function logActivity(
return requireHost().core.logActivity(type, hostId, hostName);
}
/** Tells the shell hosts were added or changed, so its lists reload. */
export function notifyHostsChanged(): void {
requireHost().core.notifyHostsChanged();
}
/** How the user wants host status shown: the brand accent, or green/red. */
export function getHostStatusColorScheme(): "accent" | "status" {
return requireHost().core.getHostStatusColorScheme();
}
/** The desktop app's local backend token, or null in a browser. */
export function getLocalAuthToken(): string | null {
return requireHost().core.getLocalAuthToken();
}
export function getHostPassword(
hostId: number,
field: "password" | "sudoPassword",
@@ -1583,3 +1618,6 @@ export function useConnectionRetry({
export type { PluginManifest } from "./manifest.js";
export type { ReactNode };
// The desktop bridge types, and the window.electronAPI global they declare.
export type * from "./desktop.js";
+104 -11
View File
@@ -14,10 +14,40 @@ import semver from "semver";
import { isKnownCapability } from "./capabilities.js";
/**
* The SDK major version this build implements. engine.api is the real
* compatibility gate; engine.termix is a display string and is never enforced.
* The plugin API this build implements. A minor bump adds to the API and
* never removes; a major bump may break plugins.
*
* engine.api is a semver range checked against this ("1" means any 1.x,
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const SUPPORTED_PLUGIN_API_VERSION = "1";
export const PLUGIN_API_VERSION = "1.0.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
semver.major(PLUGIN_API_VERSION),
);
/** Whether this build's plugin API satisfies a manifest's engine.api. */
export function isApiCompatible(range: string): boolean {
return (
semver.validRange(range) !== null &&
semver.satisfies(PLUGIN_API_VERSION, range)
);
}
/**
* Whether a core version satisfies a manifest's engine.termix. A prerelease
* core (2.9.0-beta.1) counts as that release.
*/
export function isTermixCompatible(
range: string,
coreVersion: string | null,
): boolean {
if (!coreVersion || !semver.valid(semver.coerce(coreVersion))) return true;
if (semver.validRange(range) === null) return false;
return semver.satisfies(semver.coerce(coreVersion)!.version, range);
}
export const PLUGIN_CATEGORIES = [
"Terminal",
@@ -59,14 +89,12 @@ export const RESERVED_PLUGIN_IDS: readonly string[] = [
/** A preset key is a plain property name, never a prototype one. */
const PRESET_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
/** Service names are dotted, e.g. "ssh.transport". */
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
/** Action ids name a frontend function, so segments may be camelCase. */
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/;
/**
* A plugin-relative permission name. Unlike a full id one segment is fine,
* and underscores are allowed in the first segment too ("manage_providers").
@@ -98,9 +126,12 @@ export interface PluginAuthor {
}
export interface PluginEngine {
/** Display string only, e.g. ">=2.9.0". Never enforced. */
/** Semver range of Termix releases the plugin runs on, e.g. ">=2.9.0". */
termix: string;
/** SDK major version as a string. The real gate. */
/**
* Semver range of the plugin API it needs, e.g. "1" or "^1.2". Use the
* lowest minor whose API you call, so older cores refuse it cleanly.
*/
api: string;
}
@@ -257,6 +288,11 @@ export interface PluginSettingsField {
shareRead?: "connect" | "view" | "edit" | "manage";
/** Host scope only: only the host's owner may change it. */
ownerOnly?: boolean;
/**
* JSON fields only: keys inside the stored object that hold secrets, such
* as a gateway password. Core clears them from exports like any secret.
*/
secretKeys?: string[];
/**
* Stored and validated like any field, but not drawn by the generic
* settings form, because the plugin edits it in its own UI (a host editor
@@ -344,6 +380,13 @@ export interface PluginContributions {
* off and a desktop can list them in its sync settings.
*/
syncEntities?: string[];
/**
* Other plugins' action, slot and extension point ids this plugin calls,
* fills or extends. Declaration only: nothing breaks when the owner is
* missing, but the coupling is written down where tools and people can
* see it.
*/
uses?: string[];
/**
* Keybinding actions this plugin adds to Appearance > Keybindings, with
* the parameters a saved binding carries. Core validates saved bindings
@@ -562,6 +605,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"uiPresets",
"auth",
"syncEntities",
"uses",
"keybindingActions",
"protocols",
]);
@@ -584,6 +628,7 @@ const ALLOWED_SETTINGS_FIELD = [
"defaultFrom",
"shareRead",
"ownerOnly",
"secretKeys",
];
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
@@ -738,9 +783,20 @@ function validateEngine(engine: unknown, errors: string[]): void {
}
rejectUnknown(engine, ["termix", "api"], '"engine"', errors);
requireString(engine.termix, "engine.termix", errors);
if (typeof engine.api !== "string" || !API_VERSION_PATTERN.test(engine.api)) {
if (
typeof engine.termix === "string" &&
semver.validRange(engine.termix) === null
) {
errors.push(
`Field "engine.api" must be an integer string, got: ${JSON.stringify(engine.api)}`,
`Field "engine.termix" must be a semver range, got: ${JSON.stringify(engine.termix)}`,
);
}
if (
typeof engine.api !== "string" ||
semver.validRange(engine.api) === null
) {
errors.push(
`Field "engine.api" must be a semver range such as "1" or "^1.2", got: ${JSON.stringify(engine.api)}`,
);
}
}
@@ -895,6 +951,28 @@ function validateSyncEntities(value: unknown, errors: string[]) {
}
}
function validateUses(value: unknown, errors: string[]) {
const where = "contributes.uses";
if (!Array.isArray(value) || value.length === 0) {
errors.push(`${where} must be a non-empty array`);
return;
}
const seen = new Set<string>();
for (const id of value) {
if (
typeof id !== "string" ||
!/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(id)
) {
errors.push(
`${where} entries must be dotted ids such as "terminal.open"`,
);
continue;
}
if (seen.has(id)) errors.push(`${where} duplicates "${id}"`);
seen.add(id);
}
}
function validateNameList(value: unknown, where: string, errors: string[]) {
if (!Array.isArray(value) || value.length === 0) {
errors.push(`${where} must be a non-empty array`);
@@ -1025,6 +1103,9 @@ function validateContributes(
if (contributes.syncEntities !== undefined) {
validateSyncEntities(contributes.syncEntities, errors);
}
if (contributes.uses !== undefined) {
validateUses(contributes.uses, errors);
}
validatePermissions(contributes.permissions, pluginId, errors);
validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors);
@@ -1534,6 +1615,18 @@ function validateSettings(settings: unknown, errors: string[]): void {
if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") {
errors.push(`${fieldAt}.ownerOnly must be a boolean`);
}
if ("secretKeys" in raw) {
const keys = raw.secretKeys;
if (raw.type !== "json") {
errors.push(`${fieldAt}.secretKeys is only valid on json fields`);
} else if (
!Array.isArray(keys) ||
keys.length === 0 ||
keys.some((key) => typeof key !== "string" || key.length === 0)
) {
errors.push(`${fieldAt}.secretKeys must be a non-empty string array`);
}
}
});
}
for (const scope of ["admin", "user"] as const) {
@@ -1957,10 +2050,10 @@ export function parseManifest(raw: unknown): ParsedManifest {
const manifest = raw as PluginManifest;
if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) {
if (!isApiCompatible(manifest.engine.api)) {
return {
errors: [
`Plugin targets SDK API version ${manifest.engine.api}, but this build implements ${SUPPORTED_PLUGIN_API_VERSION}`,
`Plugin needs plugin API ${manifest.engine.api}, but this build implements ${PLUGIN_API_VERSION}`,
],
};
}
+43 -11
View File
@@ -68,7 +68,7 @@ import type {
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
import type {
PluginNativeRdpRequest,
PluginExternalClientRequest,
PluginProtocolTarget,
PluginHostStatusEntry,
PluginNotification,
@@ -297,8 +297,8 @@ export interface FakePluginContext {
title?: string;
ignoreCert?: boolean;
}>;
/** Every ctx.desktop.launchNativeRdp call, in order. */
nativeRdpLaunches: PluginNativeRdpRequest[];
/** Every ctx.desktop.launchExternalClient call, in order. */
externalClientLaunches: PluginExternalClientRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>;
/** Every ctx.credentials.createSshKey call, with the id it answered. */
@@ -361,6 +361,11 @@ export interface FakePluginContext {
runScheduled: () => Promise<void>;
/** Changes the acting user, as core's request middleware would. */
setActor: (userId: string | undefined) => void;
/**
* Runs `fn` as `userId`, the way a request from that user would. For test
* code standing in for a caller; unlike ctx.asUser it needs no capability.
*/
actAs: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
/** Provided or seeded services, by name or "<service>#<provider>". */
services: Map<string, object>;
}
@@ -497,7 +502,8 @@ export function createFakeContext(
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
[];
const credentialReads: FakePluginContext["credentialReads"] = [];
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = [];
@@ -695,10 +701,12 @@ export function createFakeContext(
registry: {
provide: (key, value) => {
assertOwnRegistryKey(pluginId, key);
registryProviders.set(key, value);
},
consume: (key) => registryProviders.get(key) as never,
revoke: (key, value) => {
assertOwnRegistryKey(pluginId, key);
if (!registryProviders.has(key)) return false;
if (value !== undefined && registryProviders.get(key) !== value) {
return false;
@@ -1128,8 +1136,8 @@ export function createFakeContext(
desktopWindows.push(request);
return { success: true };
},
launchNativeRdp: async (request) => {
nativeRdpLaunches.push(request);
launchExternalClient: async (request) => {
externalClientLaunches.push(request);
return { success: true };
},
available: () => options.desktopAvailable ?? false,
@@ -1309,7 +1317,7 @@ export function createFakeContext(
hostShares,
auth,
desktopWindows,
nativeRdpLaunches,
externalClientLaunches,
credentialReads,
createdSshKeys,
notifications,
@@ -1337,6 +1345,15 @@ export function createFakeContext(
setActor: (userId) => {
actor = userId;
},
actAs: async (userId, fn) => {
const previous = actor;
actor = userId;
try {
return await fn();
} finally {
actor = previous;
}
},
services,
};
}
@@ -1477,6 +1494,11 @@ export function createMockCtx(
const gatedCtx: PluginContext = {
...ctx,
asUser: async (userId, fn) => {
require("users:impersonate");
return ctx.asUser(userId, fn);
},
db: {
define: async (definition) => {
require("db:own");
@@ -1487,7 +1509,7 @@ export function createMockCtx(
return guardedDb.client();
},
refs: async () => {
require("db:own");
require("db:core-refs");
return guardedDb.refs();
},
persist: async (persistOptions) => {
@@ -1745,9 +1767,9 @@ export function createMockCtx(
require("desktop:window");
return ctx.desktop.openIsolatedWindow(request);
},
launchNativeRdp: async (request) => {
launchExternalClient: async (request) => {
require("desktop:window");
return ctx.desktop.launchNativeRdp(request);
return ctx.desktop.launchExternalClient(request);
},
available: () => ctx.desktop.available(),
},
@@ -1882,7 +1904,7 @@ export interface TestDbOptions {
* Runs after the core stub tables exist and before the plugin's migrations,
* e.g. to create the legacy table an adoption migration renames.
*/
before?: (sqlite: TestSqlite) => void | Promise<void>;
before?: (sqlite: TestSqlite) => unknown;
/** Skip applying the migrations, to apply them by hand later. */
skipMigrations?: boolean;
}
@@ -2046,6 +2068,8 @@ export interface RenderWithAppOptions {
ready?: boolean;
/** Stands in for app.api and usePluginApi(), e.g. a stub of the routes. */
api?: import("./frontend.js").PluginApiClient;
/** What app.desktop.remoteServerUrl() answers. Read on every call. */
remoteServerUrl?: () => string | null | Promise<string | null>;
}
/** A call a plugin made on the shell, recorded instead of performed. */
@@ -2150,3 +2174,11 @@ export async function renderWithApp(
const host = (await import(/* @vite-ignore */ TEST_HOST)) as PluginTestHost;
return host.renderPlugin(plugin, options);
}
function assertOwnRegistryKey(pluginId: string, key: string): void {
if (typeof key !== "string" || !key.startsWith(`${pluginId}.`)) {
throw new Error(
`Plugin ${pluginId} may only use registry keys under "${pluginId}.", not "${key}"`,
);
}
}
+4
View File
@@ -98,6 +98,7 @@ const offline = () =>
useTabs: () => ({
openTab: noop,
openSingletonTab: noop,
connectHost: noop,
closeTab: noop,
getLayout: () => null,
applyLayout: async () => ({ skipped: [] }),
@@ -142,6 +143,9 @@ const offline = () =>
setClientPreference: () => {},
listHosts: async () => [],
listCredentials: async () => [],
notifyHostsChanged: () => {},
getHostStatusColorScheme: () => "accent",
getLocalAuthToken: () => null,
},
};
+15 -6
View File
@@ -14,12 +14,11 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Walks up from a plugin directory to the repo that holds src/ui.
* Walks up from a plugin directory to a Termix checkout, if it is in one.
*
* Plugin frontends and their tests still reach core through the "@/" alias
* (the legacy debt A7 and D1 remove), so the preset has to resolve it. A
* plugin developed outside this repo has no src/ui to point at, which is
* exactly right: the alias stops resolving and the import fails loudly.
* Inside the repo the SDK's browser entries and the test host resolve to
* core's source, so a test exercises the real registries. Outside it they
* resolve to the builds the SDK ships in dist/host.
*/
function findRepoRoot(startDir: string): string | null {
let dir = startDir;
@@ -83,7 +82,13 @@ export function pluginVitestConfig(
"@/types": path.join(repoRoot, "src", "types"),
"@": path.join(repoRoot, "src", "ui"),
}
: {}),
: {
// Outside a Termix checkout: the UI kit resolves through the
// package, and the test host is the copy the SDK ships.
"@termix/plugin-host/testing": fileURLToPath(
new URL("./host/testing-host.js", import.meta.url),
),
}),
...options.alias,
};
@@ -112,6 +117,10 @@ export function pluginVitestConfig(
environment: "jsdom",
globals: true,
setupFiles: [setupFile],
// Run the SDK through Vite rather than plain Node, so its import
// of the test host sees the alias above and the plugin and the
// host share one copy of @termix/plugin-sdk/frontend.
server: { deps: { inline: [/@termix\/plugin-sdk/] } },
include: ["tests/frontend/**/*.test.{ts,tsx}"],
},
},
+4 -1
View File
@@ -5,6 +5,8 @@
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"allowImportingTsExtensions": true,
"types": ["node", "vitest/globals"],
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
@@ -24,5 +26,6 @@
"noPropertyAccessFromIndexSignature": false,
"allowUnusedLabels": true,
"allowUnreachableCode": true
}
},
"files": ["./client.d.ts"]
}
+185
View File
@@ -0,0 +1,185 @@
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
AlertDialogAction (value): ({ className, ...props }: AlertDialogActionProps & RefAttributes<HTMLButtonElement>) => Element
AlertDialogCancel (value): ({ className, ...props }: AlertDialogCancelProps & RefAttributes<HTMLButtonElement>) => Element
AlertDialogContent (value): ({ className, ...props }: AlertDialogContentProps & RefAttributes<HTMLDivElement>) => Element
AlertDialogDescription (value): ({ className, ...props }: AlertDialogDescriptionProps & RefAttributes<HTMLParagraphElement>) => Element
AlertDialogFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
AlertDialogHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
AlertDialogOverlay (value): ({ className, ...props }: AlertDialogOverlayProps & RefAttributes<HTMLDivElement>) => Element
AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
BarSeriesItem (type): BarSeriesItem
BarSeriesProps (type): BarSeriesProps
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
ButtonProps (type): ButtonProps
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardGridCanvas (value): ({ slots, columns, editMode, renderCard, cardCatalog, onChange, cardLabel, className, }: CardGridCanvasProps) => Element
CardHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
ConnectionLogPanel (value): ({ isConnecting, isConnected, hasConnectionError, position, className, }: ConnectionLogPanelProps) => Element
ConnectionLogProvider (value): ({ children, }: { children: ReactNode; }) => Element
ConnectionOrigin (type): ConnectionOrigin
ConnectionScreen (value): ({ status, message, backgroundColor, attempt, maxAttempts, nextRetryInMs, onManualRetry, retryLabel, disconnectedMessage, extraActions, logPosition, emptyState, className, }: ConnectionScreenProps) => Element
ConnectionStage (type): ConnectionStage
ConnectionStatus (type): ConnectionStatus
CustomKeybinding (type): CustomKeybinding
Dialog (value): ({ ...props }: DialogProps) => Element
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
DialogDescription (value): ({ className, ...props }: DialogDescriptionProps & RefAttributes<HTMLParagraphElement>) => Element
DialogFooter (value): ({ className, showCloseButton, children, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
DialogHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
DialogOverlay (value): ({ className, ...props }: DialogOverlayProps & RefAttributes<HTMLDivElement>) => Element
DialogPortal (value): ({ ...props }: DialogPortalProps) => Element
DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuGroup (value): ({ ...props }: DropdownMenuGroupProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuItem (value): ({ className, inset, variant, ...props }: DropdownMenuItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; variant?: "default" | "destructive"; }) => Element
DropdownMenuLabel (value): ({ className, inset, ...props }: DropdownMenuLabelProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuPortal (value): ({ ...props }: DropdownMenuPortalProps) => Element
DropdownMenuRadioGroup (value): ({ ...props }: DropdownMenuRadioGroupProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuRadioItem (value): ({ className, children, inset, ...props }: DropdownMenuRadioItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuSeparator (value): ({ className, ...props }: DropdownMenuSeparatorProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuShortcut (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>) => Element
DropdownMenuSub (value): ({ ...props }: DropdownMenuSubProps) => Element
DropdownMenuSubContent (value): ({ className, sideOffset, alignOffset, ...props }: DropdownMenuSubContentProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: DropdownMenuSubTriggerProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes<HTMLButtonElement>) => Element
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
FOLDER_COLORS (value): string[]
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
FullScreenAppPhase (type): FullScreenAppPhase
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
GridCardCatalogEntry (type): GridCardCatalogEntry
GridColSpan (type): GridColSpan
GridLayout (type): GridLayout
GridSlot (type): GridSlot
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
HostFeatureFieldsProps (type): HostFeatureFieldsProps
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
KeyCombo (type): KeyCombo
KeybindingAction (type): KeybindingAction
KeybindingActionType (type): string
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
LineChartSeries (type): LineChartSeries
LogContext (type): LogContext
LogEntry (type): LogEntry
MFAPromptMode (type): MFAPromptMode
ManagerCardError (type): ManagerCardError
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
Popover (value): ({ ...props }: PopoverProps) => Element
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
RadialGaugeProps (type): RadialGaugeProps
RecentActivityItem (type): RecentActivityItem
RobustClipboardProvider (value): typeof RobustClipboardProvider
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
SectionCard (value): ({ title, icon, action, children, className, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; }) => Element
Select (value): ({ ...props }: SelectSharedProps & { value?: string; defaultValue?: string; onValueChange?(value: string): void; }) => Element
Select2 (value): ({ className, children, value, defaultValue, onChange, disabled, placeholder, align, contentClassName, name, id, required, "aria-label": ariaLabel, ...props }: Select2Props) => Element
SelectContent (value): ({ className, children, position, ...props }: SelectContentProps & RefAttributes<HTMLDivElement>) => Element
SelectGroup (value): ({ ...props }: SelectGroupProps & RefAttributes<HTMLDivElement>) => Element
SelectItem (value): ({ className, children, ...props }: SelectItemProps & RefAttributes<HTMLDivElement>) => Element
SelectLabel (value): ({ className, ...props }: SelectLabelProps & RefAttributes<HTMLDivElement>) => Element
SelectScrollDownButton (value): ({ className, ...props }: SelectScrollDownButtonProps & RefAttributes<HTMLDivElement>) => Element
SelectScrollUpButton (value): ({ className, ...props }: SelectScrollUpButtonProps & RefAttributes<HTMLDivElement>) => Element
SelectSeparator (value): ({ className, ...props }: SelectSeparatorProps & RefAttributes<HTMLDivElement>) => Element
SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerProps & RefAttributes<HTMLButtonElement> & { size?: "default" | "sm"; }) => Element
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
SettingRow (value): ({ label, badge, description, children, }: { label: string; badge?: string; description?: ReactNode; children: ReactNode; }) => Element
Sheet (value): ({ ...props }: DialogProps) => Element
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
SheetDescription (value): ({ className, ...props }: DialogDescriptionProps & RefAttributes<HTMLParagraphElement>) => Element
SheetFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
SparklineProps (type): SparklineProps
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
TextareaProps (type): TextareaProps
Tooltip (value): ({ ...props }: TooltipProps) => Element
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
UptimeInfo (type): UptimeInfo
VersionInfo (type): VersionInfo
WebSocketConnectionTarget (type): WebSocketConnectionTarget
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
cn (value): (...inputs: ClassValue[]) => string
copyToClipboard (value): (text: string) => Promise<boolean>
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
createQuickConnectHost (value): (input: QuickConnectInput) => Host
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
getBasePath (value): () => string
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
getDeviceId (value): () => string
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
getUptime (value): () => Promise<UptimeInfo>
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
isElectron (value): () => boolean
isMacPlatform (value): () => boolean
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
linkedServerUrl (value): () => Promise<string>
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
readFromClipboard (value): () => Promise<string>
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
resolveRemoteHostId (value): (syncId: string) => Promise<number>
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
useAppTheme (value): () => ThemeProviderState
useConfirmation (value): () => { isOpen: boolean; options: ConfirmationOptions; confirm: (opts: ConfirmationOptions, callback: () => void) => void; handleConfirm: () => void; handleCancel: () => void; confirmWithToast: (opts: string | ConfirmationOptions, callback?: () => void, variantOrConfirmLabel?: string, cancelLabel?: string, toastOptions?: ToastConfirmOptions) => Promise<boolean>; }
useConnectionLog (value): () => ConnectionLogContextType
useIsMobile (value): () => boolean
useOptionalConnectionLog (value): () => ConnectionLogContextType
useTabs (value): () => TabContextType
useTabsSafe (value): () => TabContextType
+15 -2
View File
@@ -6,13 +6,26 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"acme-client": "^5.4.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8"
}
}
+4 -2
View File
@@ -24,7 +24,8 @@
"network:outbound",
"notify:send",
"network:serve",
"ui:surface"
"ui:surface",
"users:impersonate"
],
"optionalDependencies": {
"workspaces": "^1.0.0",
@@ -185,7 +186,8 @@
}
]
}
}
},
"uses": ["terminal.sidePanel", "terminal.toolbar"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
+24 -2
View File
@@ -6,10 +6,32 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.120.0",
"lucide-react": "^1.33.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8",
"ssh2": "^1.17.0"
}
}
+1 -1
View File
@@ -113,7 +113,7 @@ describe("createAiGate", () => {
it("passes an opted-in user through with their access attached", async () => {
const next = vi.fn();
const req: { aiAccess?: unknown } = {};
const req: Parameters<ReturnType<typeof createAiGate>>[0] = {};
await createAiGate(
settings({ globallyEnabled: true }, { enabled: true }),
() => "user-1",
+18 -3
View File
@@ -6,14 +6,29 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"lucide-react": "^1.33.0",
"nodemailer": "^10.0.10"
},
"devDependencies": {
"@termix/plugin-sdk": "*",
"@types/nodemailer": "^8.0.2"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8"
}
}
+4 -2
View File
@@ -24,7 +24,8 @@
"network:outbound",
"network:serve",
"settings:read-core",
"ui:surface"
"ui:surface",
"users:impersonate"
],
"optionalDependencies": {
"fleets": "^1.0.0",
@@ -119,7 +120,8 @@
],
"http": {
"legacyPaths": ["/automations/webhook"]
}
},
"uses": ["alerts.openChannels"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"],
+21 -2
View File
@@ -6,10 +6,29 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8",
"ssh2": "^1.17.0"
}
}
@@ -61,7 +61,7 @@ describe("automations activate", () => {
capabilities: manifest.capabilities.filter((c) => c !== "notify:send"),
});
await expect(
withoutNotify.ctx.notify.send([1], { title: "t", body: "b" }),
withoutNotify.ctx.notify.send({ title: "t", body: "b" } as never),
).rejects.toBeInstanceOf(PluginCapabilityError);
});
@@ -99,7 +99,7 @@ describe("onMetrics", () => {
});
expect(run).toHaveBeenCalledTimes(1);
expect(run.mock.calls[0][0]).toMatchObject({
expect((run.mock.calls[0] as unknown[])[0]).toMatchObject({
triggerType: "metric_threshold",
triggerHostId: 7,
});
+4 -2
View File
@@ -20,7 +20,8 @@
"ssh:connect",
"network:serve",
"ui:surface",
"events:core"
"events:core",
"users:impersonate"
],
"provides": [
{
@@ -93,7 +94,8 @@
"viewMode": "detail",
"containerLayout": "table"
}
}
},
"uses": ["homepage.widgets", "terminal.resolveTheme"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
+35 -2
View File
@@ -6,10 +6,43 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.33.0",
"react-xtermjs": "^1.0.10"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8",
"ssh2": "^1.17.0",
"ws": "^8.21.3"
},
"termix": {
"patches": [
"xterm-android-ime"
]
},
"overrides": {
"react-xtermjs": {
"@xterm/xterm": "$@xterm/xterm"
}
}
}
+2
View File
@@ -1,3 +1,5 @@
// The terminal surfaces need xterm's own stylesheet.
import "@xterm/xterm/css/xterm.css";
import type { ComponentType } from "react";
import { Box } from "lucide-react";
import type {
+6 -2
View File
@@ -1,7 +1,8 @@
{
"nav": {
"files": "Files",
"sftp": "SFTP"
"sftp": "SFTP",
"openFileManager": "Open File Manager"
},
"permissions": {
"use": {
@@ -520,6 +521,9 @@
"transferError": "Transfer failed",
"transferFailedRetryHint": "Partial data was kept on the destination. Retry will resume when the connection is back.",
"transferPartialHint": "Could not transfer: {{paths}}",
"transferSuccess": "Transfer complete"
"transferSuccess": "Transfer complete",
"metricsHopSourceRead": "Source → server: {{throughput}}",
"metricsHopDestSftpWrite": "Server → dest (SFTP): {{throughput}}",
"metricsHopDestLocalWrite": "Server → dest (local): {{throughput}}"
}
}
+15 -2
View File
@@ -23,7 +23,8 @@
"network:serve",
"ui:surface",
"events:core",
"kv:own"
"kv:own",
"users:impersonate"
],
"provides": [
{
@@ -108,7 +109,19 @@
"key": "enableFileManager",
"labelKey": "hostCapability.label",
"editorTab": "plugins"
}
},
"uiPresets": {
"simple": {
"viewMode": "grid"
},
"balanced": {
"viewMode": "grid"
},
"advanced": {
"viewMode": "list"
}
},
"uses": ["homepage.widgets", "host-metrics.disk", "terminal.open"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
+48 -2
View File
@@ -6,10 +6,56 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.1",
"@codemirror/search": "^6.7.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.12",
"@monaco-editor/react": "^4.7.0",
"@tanstack/react-virtual": "^3.14.13",
"@uiw/codemirror-extensions-langs": "^4.25.11",
"@uiw/react-codemirror": "^4.25.11",
"busboy": "^1.6.0",
"jszip": "^3.10.2",
"lucide-react": "^1.33.0",
"motion": "^13.1.1",
"pdfjs-dist": "5.4.296",
"react-h5-audio-player": "^3.10.2",
"react-icons": "^5.7.0",
"react-markdown": "^10.1.0",
"react-pdf": "^10.5.0",
"react-photo-view": "^1.2.7",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"axios": "^1.19.0",
"cookie-parser": "^1.4.7",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"i18next": "^26.4.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.14",
"sonner": "^2.0.8",
"ssh2": "^1.17.0"
}
}
@@ -46,6 +46,7 @@ import {
} from "@termix/plugin-sdk/ui";
import { ConnectionLogPanel } from "@termix/plugin-sdk/ui";
import {
usePluginUiPreferences,
invokeAction,
useConnectionRetry,
logActivity,
@@ -177,25 +178,32 @@ function FileManagerContent({
const [isReconnecting, setIsReconnecting] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState("");
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
const [viewMode, setViewMode] = useState<"grid" | "list">(() => {
const saved = localStorage.getItem("fileManagerViewMode");
return saved === "grid" || saved === "list" ? saved : "grid";
});
// An interface preset sets this too, so it is a plugin UI preference.
const { values: fmPrefs, set: setFmPref } = usePluginUiPreferences<{
viewMode: "grid" | "list";
}>();
const viewMode: "grid" | "list" =
fmPrefs.viewMode === "list" ? "list" : "grid";
const setViewMode = useCallback(
(next: "grid" | "list") => setFmPref("viewMode", next),
[setFmPref],
);
useEffect(() => {
// Before 2.9 the view mode lived in this browser's localStorage.
try {
const legacy = localStorage.getItem("fileManagerViewMode");
if (legacy === null) return;
localStorage.removeItem("fileManagerViewMode");
if (legacy === "grid" || legacy === "list") setFmPref("viewMode", legacy);
} catch {
// Storage can be unavailable.
}
}, []);
const [density, setDensity] = useState<"comfortable" | "compact">(() =>
localStorage.getItem("fileManagerDensity") === "compact"
? "compact"
: "comfortable",
);
// Picking an interface preset seeds this key from another part of the app.
useEffect(() => {
const handler = () => {
const saved = localStorage.getItem("fileManagerViewMode");
if (saved === "grid" || saved === "list") setViewMode(saved);
};
window.addEventListener("fileManagerViewModeChanged", handler);
return () =>
window.removeEventListener("fileManagerViewModeChanged", handler);
}, []);
const [sortBy, setSortBy] = useState<"name" | "modified" | "size">(() => {
const saved = localStorage.getItem("fileManagerSortBy");
return saved === "name" || saved === "modified" || saved === "size"
@@ -3328,10 +3336,6 @@ function FileManagerContent({
}
}, [currentHost?.id]);
useEffect(() => {
localStorage.setItem("fileManagerViewMode", viewMode);
}, [viewMode]);
useEffect(() => {
localStorage.setItem("fileManagerDensity", density);
}, [density]);
@@ -15,7 +15,7 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
const VIEWPORT_PADDING = 16;
@@ -28,7 +28,7 @@ import {
} from "lucide-react";
import { cn } from "@termix/plugin-sdk/ui";
import { Button, Input } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
import {
createLocalFile,
createLocalFolder,
@@ -9,10 +9,11 @@
// configured Remote Sync server) and the main process resolves the rest, so a
// compromised renderer cannot turn the transfer bridge into an HTTP client.
import type {
LocalTransferOrigin,
LocalTransferProgress,
} from "@termix/plugin-sdk/ui";
import {
getLocalAuthToken,
type LocalTransferOrigin,
type LocalTransferProgress,
} from "@termix/plugin-sdk/frontend";
import { getSessionOrigin } from "./client";
import { getDeviceId } from "@termix/plugin-sdk/ui";
@@ -67,11 +68,7 @@ function transferOriginFor(sessionId: string): LocalTransferOrigin {
*/
function localAuthTokenFor(origin: LocalTransferOrigin): string | undefined {
if (origin !== "local") return undefined;
try {
return localStorage.getItem("jwt") || undefined;
} catch {
return undefined;
}
return getLocalAuthToken() ?? undefined;
}
export function createLocalTransferId(prefix: string): string {
@@ -3,7 +3,7 @@ import { createElement } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { FileItem } from "../host-types";
import type { LocalWalkResult } from "@termix/plugin-sdk/ui";
import type { LocalWalkResult } from "@termix/plugin-sdk/frontend";
import { useSettings } from "@termix/plugin-sdk/frontend";
import {
cancelLocalTransfer,
+22 -3
View File
@@ -1,6 +1,6 @@
import { fileManagerHostSetting } from "./host-settings";
import type { ComponentType } from "react";
import { FolderSearch, ArrowLeftRight } from "lucide-react";
import { FolderOpen, FolderSearch, ArrowLeftRight } from "lucide-react";
import {
invokeAction,
type PluginHostRecord,
@@ -70,8 +70,14 @@ function openEditorAction(
app.tabs.openTab(host, "files", { data: { initialFilePath: filePath } });
}
/** Where this plugin serves the streaming routes the desktop app calls. */
const TRANSFER_API_PATH = "/plugin-api/file-manager";
export function activate(app: TermixApp): void {
setFileManagerApp(app);
if (typeof window !== "undefined") {
void window.electronAPI?.localTransfer?.setApiPath?.(TRANSFER_API_PATH);
}
app.onDispose(() => setFileManagerApp(null));
app.registerTab("files", FilesTab as unknown as ComponentType<TabProps>, {
icon: FolderSearch,
@@ -135,8 +141,21 @@ export function activate(app: TermixApp): void {
const openHost = ((host: PluginHostRecord | null, path?: string) =>
openHostAction(app, host, path)) as never;
app.registerAction("files.openHost", openHost);
// The shell's "open in file manager" tab button asks for this.
app.registerAction("host.openFiles", openHost);
// "Open File Manager" in the tab bar's menu, for a terminal tab. The
// terminal answers with its working directory and opens the files tab.
app.registerAction("file-manager.openFromTab", ((handle: unknown) =>
(
handle as { openFileManager?: () => void } | null
)?.openFileManager?.()) as never);
app.registerSlotContribution("tab.menu", {
actionId: "file-manager.openFromTab",
titleKey: "nav.openFileManager",
icon: FolderOpen,
kind: "button",
when: (context) =>
typeof (context.handle as { openFileManager?: unknown } | null)
?.openFileManager === "function",
});
app.registerAction("files.openEditor", ((
host: PluginHostRecord | null,
filePath: string,
@@ -8,7 +8,7 @@ import type {
LocalFsResult,
LocalTrashResult,
LocalWalkResult,
} from "@termix/plugin-sdk/ui";
} from "@termix/plugin-sdk/frontend";
import { isElectron } from "@termix/plugin-sdk/ui";
export function isLocalFileBrowserAvailable(): boolean {
@@ -1,7 +1,7 @@
// Pure helpers shared by the dual-pane (local <-> remote) file manager code.
// Kept free of React/DOM so they can be unit tested directly.
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
/** Custom MIME type carried by drags that originate in the local pane. */
export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files";
@@ -12,7 +12,9 @@ function setup(remote: object, owned = true) {
(req: Request, res: Response) => Promise<unknown>
>();
registerFileDownloadRoutes(
{ post: (path, handler) => routes.set(path, handler) } as Express,
{
post: (path, handler) => routes.set(path, handler),
} as unknown as Express,
{
ctx: {
currentActor: () => "user",
@@ -6,7 +6,7 @@ import {
LOCAL_FILES_DRAG_MIME,
REMOTE_FILES_DRAG_MIME,
} from "../../src/frontend/local-transfer-utils";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
// jsdom has no layout, so render every row instead of a virtual window.
vi.mock("@tanstack/react-virtual", () => ({
@@ -57,12 +57,15 @@ describe(`${manifest.id} activate`, () => {
expect(rendered.registered.hostEditorSections()).toEqual([]);
});
it("registers its open actions, including the shell's host.openFiles", async () => {
it("registers its open actions and the tab menu entry", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.actions().sort()).toEqual([
"file-manager.openFromTab",
"files.openEditor",
"files.openHost",
"host.openFiles",
]);
expect(rendered.registered.slot("tab.menu")).toEqual([
"file-manager.openFromTab",
]);
});
@@ -25,7 +25,7 @@ import {
serializeLocalFilesDragPayload,
sortLocalEntries,
} from "../../src/frontend/local-transfer-utils";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
describe("drag payloads", () => {
it("round-trips local file payloads", () => {
+2 -1
View File
@@ -70,6 +70,7 @@
"descriptionKey": "permissions.execute.description",
"defaultRoles": ["user"]
}
]
],
"uses": ["snippets.extractInputs"]
}
}
+23 -2
View File
@@ -6,10 +6,31 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"clsx": "^2.1.1",
"jszip": "^3.10.2",
"lucide-react": "^1.33.0",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"multer": "^2.3.0",
"react": "^19.2.8",
"sonner": "^2.0.8",
"ssh2": "^1.17.0"
}
}
@@ -20,7 +20,7 @@ afterEach(() => {
});
function fakeHosts(hosts: PluginHostSummary[]): PluginHosts {
return {
return <PluginHosts>(<unknown>{
list: async () => hosts,
get: async (hostId) => hosts.find((h) => h.id === hostId) ?? null,
checkAccess: async (hostId) => {
@@ -37,7 +37,7 @@ function fakeHosts(hosts: PluginHostSummary[]): PluginHosts {
share: async (hostId) => ({ hostId, shared: true }),
listUsers: async () => [],
listRoles: async () => [],
};
});
}
async function setup(hosts: PluginHostSummary[]) {
+2 -3
View File
@@ -19,15 +19,14 @@ describe("fleets.access", () => {
const service = server.mock.services.get("fleets.access") as FleetsService;
await expect(
server.mock.ctx.asUser("user-1", () => service.create({ name: "prod" })),
server.mock.actAs("user-1", () => service.create({ name: "prod" })),
).rejects.toThrow("fleets.manage");
});
it("adds only hosts the caller can see", async () => {
server = await startServer();
const service = server.mock.services.get("fleets.access") as FleetsService;
const run = <T>(fn: () => Promise<T>) =>
server!.mock.ctx.asUser("user-1", fn);
const run = <T>(fn: () => Promise<T>) => server!.mock.actAs("user-1", fn);
const fleet = await run(() => service.create({ name: "prod" }));
await run(() => service.addMember(fleet.id, 10));
+19 -2
View File
@@ -6,10 +6,27 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8"
}
}
@@ -333,7 +333,9 @@ export function HomepageCanvas({
scheduleSave(next);
return next;
});
} catch {}
} catch {
// A bad layout write is dropped; the canvas keeps what it shows.
}
},
[scheduleSave],
);
@@ -376,7 +378,9 @@ export function HomepageCanvas({
scheduleSave(next);
return next;
});
} catch {}
} catch {
// A bad layout write is dropped; the canvas keeps what it shows.
}
},
[scheduleSave],
);
@@ -403,7 +407,9 @@ export function HomepageCanvas({
setWidgets((prev) =>
prev.map((w) => (w.id === id ? { ...w, title, config } : w)),
);
} catch {}
} catch {
// A bad layout write is dropped; the canvas keeps what it shows.
}
},
[],
);
-4
View File
@@ -93,10 +93,6 @@ export function activate(app: TermixApp): void {
component: HomepagePreviewCard,
});
app.declareActionSlot({
id: "dashboard.secondaryView",
accepts: ["component"],
});
app.registerSlotContribution("dashboard.secondaryView", {
actionId: "homepage",
titleKey: "nav.homepage",
@@ -4,7 +4,7 @@ import {
useHostActions,
useHostStatus,
useHosts,
type PluginHostRecord,
useTabs,
} from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
@@ -18,14 +18,6 @@ import { GRID_SIZE } from "../types.js";
import { quickConnectTargets } from "../quick-connect-targets.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
function openTab(host: PluginHostRecord, type: QuickConnectType) {
window.dispatchEvent(
new CustomEvent("termix:open-tab", {
detail: { hostId: String(host.id), type },
}),
);
}
function StatusDot({ hostId }: { hostId: number }) {
const status = useHostStatus(hostId);
const cls =
@@ -43,6 +35,7 @@ function QuickConnectWidget({
}: WidgetComponentProps<QuickConnectConfig>) {
const { t } = useTranslation();
const { hosts: allHosts, loaded } = useHosts();
const tabs = useTabs();
const targets = quickConnectTargets(useHostActions());
const hosts =
@@ -104,7 +97,7 @@ function QuickConnectWidget({
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
openTab(host, target.type);
tabs.connectHost(host, target.type);
}}
className="p-1.5 text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors border border-transparent hover:border-accent-brand/20"
>
+8 -2
View File
@@ -22,7 +22,8 @@
"db:own",
"network:serve",
"ui:surface",
"events:core"
"events:core",
"users:impersonate"
],
"provides": [
{
@@ -153,7 +154,12 @@
"advanced": {
"columns": 4
}
}
},
"uses": [
"homepage.hostMetrics",
"homepage.widgets",
"terminal.toolbarStatus"
]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
+22 -2
View File
@@ -6,10 +6,30 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8",
"ssh2": "^1.17.0"
}
}
@@ -26,7 +26,6 @@ type PluginSettingsBag = Record<string, Record<string, unknown>>;
*/
export function HostStatsTab({
form,
setField,
updateForm,
host,
}: HostEditorSectionProps) {
@@ -27,7 +27,7 @@ function RowBar({ label, value }: { label: string; value: number }) {
);
}
/** CPU, RAM and disk bars in a dashboard host row ("dashboard.hostMetrics"). */
/** CPU, RAM and disk bars in a dashboard host row ("dashboard.hostRow"). */
export function createDashboardHostMetrics(store: MetricsSummaryStore) {
return function DashboardHostMetrics(props: Record<string, unknown>) {
const { t } = useTranslation();
@@ -13,13 +13,6 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
function computeChartData(rows: MetricsHistoryRow[]) {
return {
data: rows.map((r) => r.cpu_percent),
timestamps: rows.map((r) => r.ts),
};
}
export function CpuCard({
metrics,
history,
+1 -1
View File
@@ -111,7 +111,7 @@ export async function activate(app: TermixApp): Promise<void> {
});
// The usage bars in the dashboard's host status card.
app.registerSlotContribution("dashboard.hostMetrics", {
app.registerSlotContribution("dashboard.hostRow", {
actionId: "host-metrics.dashboardHost",
titleKey: "nav.hostMetrics",
kind: "component",
@@ -32,8 +32,10 @@ describe("deriveEnabledWidgets", () => {
it("output is always a subset of the 10 known WidgetTypes", () => {
const slots = METRIC_CARD_IDS.map((id) => ({ id })).concat([
{ id: "user_manager" } as { id: (typeof METRIC_CARD_IDS)[number] },
{ id: "bogus" } as { id: (typeof METRIC_CARD_IDS)[number] },
{ id: "user_manager" } as unknown as {
id: (typeof METRIC_CARD_IDS)[number];
},
{ id: "bogus" } as unknown as { id: (typeof METRIC_CARD_IDS)[number] },
]);
const out = deriveEnabledWidgets(slots);
expect(out).toEqual(METRIC_CARD_IDS);
@@ -40,7 +40,7 @@ describe(`${manifest.id} activate`, () => {
it("fills core's metric slots and offers disk usage to other plugins", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.slot("dashboard.hostMetrics")).toEqual([
expect(rendered.registered.slot("dashboard.hostRow")).toEqual([
"host-metrics.dashboardHost",
]);
expect(rendered.registered.slot("homepage.hostMetrics")).toEqual([
+19 -4
View File
@@ -6,14 +6,29 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"ldapjs": "^3.0.7"
"ldapjs": "^3.0.7",
"lucide-react": "^1.33.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*",
"@types/ldapjs": "^3.0.6"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8"
}
}
+15 -2
View File
@@ -6,14 +6,27 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"cytoscape": "^3.34.3",
"lucide-react": "^1.33.0",
"react-cytoscapejs": "^2.0.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8"
}
}
@@ -31,6 +31,8 @@ import {
useTranslation,
usePluginApi,
useHosts,
useTabs,
getHostStatusColorScheme,
useHostActions,
type PluginHostRecord,
} from "@termix/plugin-sdk/frontend";
@@ -101,24 +103,6 @@ interface NetworkGraphCardProps {
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
type StatusColorScheme = "accent" | "status";
/** Reads the sidebar preferences cache directly, same as core's status dots do. */
function readStatusColorScheme(): StatusColorScheme {
try {
const raw = localStorage.getItem("hostSidebarPreferences");
if (!raw) return "accent";
const parsed = JSON.parse(raw) as {
display?: { statusColorScheme?: string };
};
return parsed?.display?.statusColorScheme === "status"
? "status"
: "accent";
} catch {
return "accent";
}
}
function resolveCssVar(varName: string, fallback: string): string {
const raw = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
@@ -151,7 +135,7 @@ function buildNodeSvg(
): string {
const isOnline = status === "online";
const isOffline = status === "offline";
const useRealColors = readStatusColorScheme() === "status";
const useRealColors = getHostStatusColorScheme() === "status";
let statusColor: string;
if (isOnline) {
statusColor = useRealColors
@@ -583,10 +567,10 @@ export function NetworkGraphCard({
const hideMenu = () => setContextMenu((p) => ({ ...p, visible: false }));
const shellTabs = useTabs();
const fireOpen = (hostId: string, type?: string) => {
window.dispatchEvent(
new CustomEvent("termix:open-tab", { detail: { hostId, type } }),
);
const host = liveHosts.find((candidate) => String(candidate.id) === hostId);
if (host) shellTabs.connectHost(host, type);
};
const handleContextAction = (action: string) => {
@@ -38,7 +38,7 @@ describe("network-topology activate", () => {
const service = server.mock.services.get("network-topology.graph") as {
get: () => Promise<unknown>;
};
const result = await server.mock.ctx.asUser("user-1", () => service.get());
const result = await server.mock.actAs("user-1", () => service.get());
expect(result).toEqual({ nodes: [{ data: { id: "1" } }], edges: [] });
});
@@ -47,7 +47,7 @@ describe("network-topology activate", () => {
const service = server.mock.services.get("network-topology.graph") as {
get: () => Promise<unknown>;
};
const result = await server.mock.ctx.asUser("user-1", () => service.get());
const result = await server.mock.actAs("user-1", () => service.get());
expect(result).toBeNull();
});
+2 -1
View File
@@ -52,7 +52,8 @@
"to": "/callback"
}
]
}
},
"uses": ["terminal.overlay"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
+20 -4
View File
@@ -6,14 +6,30 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"js-yaml": "^5.2.3"
"js-yaml": "^5.2.3",
"lucide-react": "^1.33.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*",
"@types/js-yaml": "^4.0.9"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"axios": "^1.19.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8"
}
}
+2 -1
View File
@@ -88,7 +88,8 @@
"proxmoxLastSyncSummary": "{{created}} created, {{updated}} updated, {{markedMissing}} missing, {{skipped}} skipped",
"proxmoxLastSyncStatus": {
"error": "Failed",
"pending": "Pending"
"pending": "Pending",
"success": "Success"
},
"proxmoxDiscoverAction": "Discover & import Proxmox guests",
"proxmoxImportTitle": "Import from Proxmox",
+2 -1
View File
@@ -21,7 +21,8 @@
"ssh:connect",
"db:own",
"network:serve",
"ui:surface"
"ui:surface",
"users:impersonate"
],
"contributes": {
"tabs": [
+19 -2
View File
@@ -6,10 +6,27 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8",
"ssh2": "^1.17.0"
}
}
@@ -1,5 +1,8 @@
import { useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
notifyHostsChanged,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@termix/plugin-sdk/ui";
@@ -201,7 +204,7 @@ export function ProxmoxDiscoverDialog({
if (toImport.length) {
const updated = await listHosts();
onHostsChanged(updated);
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
notifyHostsChanged();
}
const msg = [
@@ -1,12 +1,4 @@
const SECRET_BACKED_AUTH_TYPES = new Set(["password", "key"]);
const SECRETLESS_AUTH_TYPES = new Set([
"none",
"agent",
"opkssh",
"stepca",
"tailscale",
"vault",
]);
export type ProxmoxImportAuth = {
authType: string;
@@ -18,8 +10,13 @@ export function resolveProxmoxImportAuth(
defaultAuthType: string | undefined,
credentialId: number | null | undefined,
): ProxmoxImportAuth {
// An explicit secretless auth choice (none/opkssh/tailscale/vault) wins.
if (defaultAuthType && SECRETLESS_AUTH_TYPES.has(defaultAuthType)) {
// Any auth type that needs no stored secret (none, agent, or one a plugin
// adds) wins, the same rule the backend copy applies.
if (
defaultAuthType &&
defaultAuthType !== "credential" &&
!SECRET_BACKED_AUTH_TYPES.has(defaultAuthType)
) {
return { authType: defaultAuthType };
}
@@ -35,14 +32,5 @@ export function resolveProxmoxImportAuth(
};
}
// Explicit non secret-backed special type without a credential.
if (
defaultAuthType &&
defaultAuthType !== "credential" &&
!SECRET_BACKED_AUTH_TYPES.has(defaultAuthType)
) {
return { authType: defaultAuthType };
}
return { authType: "none" };
}
@@ -86,7 +86,7 @@ describe("ProxmoxPollingManager", () => {
};
const manager = new ProxmoxPollingManager<TestHost>({
fetchHostById,
withSshConnection,
withSshConnection: withSshConnection as never,
historyRepository,
});
return { manager, fetchHostById, withSshConnection, historyRepository };
+7 -4
View File
@@ -7,7 +7,7 @@
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Plugin-RemoteDesktop",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Terminal",
"icon": "Monitor",
"engine": {
@@ -21,7 +21,8 @@
"ssh:connect",
"network:serve",
"desktop:window",
"ui:surface"
"ui:surface",
"users:impersonate"
],
"optionalDependencies": {
"session-recording": "^1.0.0",
@@ -442,7 +443,8 @@
"labelKey": "settings.host.guacamoleConfig.label",
"default": {},
"hidden": true,
"shareRead": "edit"
"shareRead": "edit",
"secretKeys": ["gateway-password"]
},
{
"key": "enableToolbar",
@@ -454,7 +456,8 @@
}
]
}
}
},
"uses": ["session.remoteDisplay", "wakeOnLan.macAddress"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
+27 -2
View File
@@ -6,10 +6,35 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"guacamole-common-js": "^1.5.0",
"guacamole-lite": "^1.2.0",
"lucide-react": "^1.33.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"express": "^5.2.1",
"react": "^19.2.8",
"react-i18next": "^17.0.14",
"sonner": "^2.0.8",
"ssh2": "^1.17.0"
},
"termix": {
"patches": [
"guacamole-common-js-flush"
]
}
}
@@ -1,38 +1,31 @@
const fs = require("fs");
const path = require("path");
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
// the package resolves the way the plugin's bundle will resolve it.
function packageDir(name) {
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
// Walked by hand: a package whose exports map hides package.json cannot be
// found with require.resolve.
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
const candidate = path.join(dir, "node_modules", name);
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
}
}
const guacdClientPath = path.join(
__dirname,
"..",
"node_modules",
"guacamole-lite",
packageDir("guacamole-lite"),
"lib",
"GuacdClient.js",
);
const cryptPath = path.join(
__dirname,
"..",
"node_modules",
"guacamole-lite",
"lib",
"Crypt.js",
);
const cryptPath = path.join(packageDir("guacamole-lite"), "lib", "Crypt.js");
const clientConnectionPath = path.join(
__dirname,
"..",
"node_modules",
"guacamole-lite",
packageDir("guacamole-lite"),
"lib",
"ClientConnection.js",
);
const serverPath = path.join(
__dirname,
"..",
"node_modules",
"guacamole-lite",
"lib",
"Server.js",
);
const serverPath = path.join(packageDir("guacamole-lite"), "lib", "Server.js");
if (
!fs.existsSync(guacdClientPath) ||
+2 -1
View File
@@ -636,7 +636,8 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
return res.status(400).json({ error: "Missing host" });
}
try {
const result = await ctx.desktop.launchNativeRdp({
const result = await ctx.desktop.launchExternalClient({
protocol: "rdp",
host: body.host,
port: Number(body.port) || DEFAULT_PORT.rdp,
username: typeof body.username === "string" ? body.username : undefined,
@@ -117,17 +117,8 @@ export class GuacamoleTokenService {
}
}
const jwtSecret = process.env.JWT_SECRET;
if (jwtSecret) {
return crypto
.createHash("sha256")
.update(jwtSecret + "_guacamole")
.digest();
}
this.log.warn("No persistent encryption key found, generating random key", {
operation: "guac_key_generation",
});
// A token lives for seconds between issue and connect, so a fresh key
// per boot is enough and nothing is derived from core's own secrets.
return crypto.randomBytes(KEY_LENGTH);
}
@@ -15,13 +15,13 @@ import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
import {
buildOriginWsUrl,
getBasePath,
guacStateToStage,
isElectron,
resolveConnectionOrigin,
type ConnectionOrigin,
type ConnectionStage,
} from "@termix/plugin-sdk/ui";
import { errorMessage } from "./host-remote";
import { guacStateToStage } from "./guacamole-state.ts";
import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts";
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
import { bindPointerInput } from "./guacamole-pointer.ts";
@@ -88,8 +88,6 @@ interface GuacamoleDisplayProps {
onZoomChange?: (zoom: number) => void;
}
const isDev = import.meta.env.DEV;
export const GuacamoleDisplay = forwardRef<
GuacamoleDisplayHandle,
GuacamoleDisplayProps
@@ -0,0 +1,26 @@
import type { ConnectionStage } from "@termix/plugin-sdk/ui";
// Guacamole client states, per guacamole-common-js Guacamole.Client#STATE_*.
const GUAC_STATE_IDLE = 0;
const GUAC_STATE_CONNECTING = 1;
const GUAC_STATE_WAITING = 2;
const GUAC_STATE_CONNECTED = 3;
const GUAC_STATE_DISCONNECTING = 4;
const GUAC_STATE_DISCONNECTED = 5;
export function guacStateToStage(state: number): ConnectionStage {
switch (state) {
case GUAC_STATE_CONNECTING:
return "guac_connecting";
case GUAC_STATE_WAITING:
return "guac_handshake";
case GUAC_STATE_CONNECTED:
return "guac_ready";
case GUAC_STATE_DISCONNECTING:
case GUAC_STATE_DISCONNECTED:
return "guac_disconnected";
case GUAC_STATE_IDLE:
default:
return "guac_connecting";
}
}
@@ -43,7 +43,7 @@ describe("createMacosVncCompatibilityProxy", () => {
socket.write(Buffer.from([5, 30, 33]));
socket.write(Buffer.from([36, 2, 35]));
socket.once("data", (selection) => {
selectedSecurityType = selection[0];
selectedSecurityType = Number(selection[0]);
socket.write("desktop-data");
});
});
@@ -17,8 +17,8 @@ import { GuacamoleTokenService } from "../../src/backend/token-service.js";
const manifest = manifestJson as unknown as PluginManifest;
// The token key comes from JWT_SECRET; without one each service picks its own.
process.env.JWT_SECRET = "remote-desktop-test-secret";
// Without a fixed key each token service picks its own.
process.env.GUACAMOLE_ENCRYPTION_KEY = "ab".repeat(32);
function target(
overrides: Partial<PluginProtocolTarget["auth"]> = {},
@@ -10,7 +10,7 @@ import {
isServerOwnedSetting,
} from "../../src/backend/token-service.js";
process.env.JWT_SECRET = "remote-desktop-test-secret";
process.env.GUACAMOLE_ENCRYPTION_KEY = "ab".repeat(32);
function decode(token: string) {
return JSON.parse(Buffer.from(token, "base64").toString("utf8")) as {
@@ -59,8 +59,10 @@ describe("GuacamoleTokenService", () => {
const termixMeta = {
termixConnectId: "connect-1",
hostId: 7,
hostName: "windows",
ownerUserId: "user-1",
protocol: "rdp" as const,
tabInstanceId: null,
};
const token = tokenService.createRdpToken(
"windows.example.test",
+2 -1
View File
@@ -20,7 +20,8 @@
"network:outbound",
"network:serve",
"auth:provide",
"ui:surface"
"ui:surface",
"users:impersonate"
],
"contributes": {
"auth": {
+20 -2
View File
@@ -6,10 +6,28 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8"
}
}
+2 -1
View File
@@ -33,7 +33,8 @@
"titleKey": "nav.serial",
"icon": "Usb"
}
]
],
"uses": ["terminal.resolveTheme"]
},
"desktop": "local"
}
+28 -2
View File
@@ -6,13 +6,39 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.33.0",
"react-xtermjs": "^1.0.10",
"serialport": "^13.0.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"react": "^19.2.8",
"ws": "^8.21.3"
},
"termix": {
"patches": [
"xterm-android-ime"
]
},
"overrides": {
"react-xtermjs": {
"@xterm/xterm": "$@xterm/xterm"
}
}
}
+2
View File
@@ -1,3 +1,5 @@
// The terminal surfaces need xterm's own stylesheet.
import "@xterm/xterm/css/xterm.css";
import type { Ref } from "react";
import { Usb } from "lucide-react";
import type {
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff. Show more