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 # Stage 1: Install dependencies
FROM node:26-slim AS deps FROM node:26-slim AS deps
WORKDIR /app 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/* RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package*.json ./ 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 packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
COPY plugins/acme-ssl/package.json ./plugins/acme-ssl/package.json COPY --from=workspace-manifests /src/plugins ./plugins
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 .npmrc ./ COPY .npmrc ./
COPY vendor ./vendor 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 && \ RUN npm ci --ignore-scripts && \
node scripts/patch-guacamole-lite.cjs && \
node scripts/patch-ssh2-agent.cjs && \ node scripts/patch-ssh2-agent.cjs && \
npm cache clean --force npm cache clean --force
@@ -110,10 +82,15 @@ COPY packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
COPY .npmrc ./ COPY .npmrc ./
COPY vendor ./vendor 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 && \ 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 && \ node scripts/patch-ssh2-agent.cjs && \
rm -rf node_modules/better-sqlite3/prebuilds && \ rm -rf node_modules/better-sqlite3/prebuilds && \
npm run build-release --prefix node_modules/better-sqlite3 && \ npm run build-release --prefix node_modules/better-sqlite3 && \
+32 -10
View File
@@ -35,6 +35,7 @@ const IPC = {
DOWNLOAD: "local-transfer:download", DOWNLOAD: "local-transfer:download",
CANCEL: "local-transfer:cancel", CANCEL: "local-transfer:cancel",
PROGRESS: "local-transfer:progress", PROGRESS: "local-transfer:progress",
SET_API: "local-transfer:set-api",
}; };
const READ_CHUNK_BYTES = 1024 * 1024; 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. // 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_-]+$/; 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 // The plugin that serves the two streaming routes tells us its /plugin-api/
// on the main backend. // path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
const FILE_MANAGER_API = "/plugin-api/file-manager"; // which keeps every transfer on the backend's plugin routes.
const DEFAULT_LOCAL_FILE_MANAGER_BASE = `http://localhost:30001${FILE_MANAGER_API}`; 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) { function normalizeHttpBase(candidate, what) {
let parsed; let parsed;
@@ -346,10 +356,13 @@ function normalizeHttpBase(candidate, what) {
// Resolves where a transfer may go. Only the two file-manager streaming // Resolves where a transfer may go. Only the two file-manager streaming
// routes are reachable, and only on the embedded backend or the linked // routes are reachable, and only on the embedded backend or the linked
// server; credentials come from the main process, not the caller. // server; credentials come from the main process, not the caller.
function createTargetResolver({ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
localBaseUrl = DEFAULT_LOCAL_FILE_MANAGER_BASE, const resolveApiPath = () => {
getLinkedServer, const resolved = apiPath ?? registeredApiPath;
}) { if (!resolved)
throw new Error("Local file transfers are not available yet");
return resolved;
};
return function resolveTransferTarget({ return function resolveTransferTarget({
origin, origin,
route, route,
@@ -389,7 +402,10 @@ function createTargetResolver({
headers.Authorization = `Bearer ${authToken}`; headers.Authorization = `Bearer ${authToken}`;
} }
return { return {
url: `${normalizeHttpBase(localBaseUrl, "Local backend URL")}${routePath}`, url: `${normalizeHttpBase(
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`,
"Local backend URL",
)}${routePath}`,
headers, headers,
}; };
} }
@@ -402,7 +418,7 @@ function createTargetResolver({
} }
const base = normalizeHttpBase(linked.serverUrl, "Linked server URL"); const base = normalizeHttpBase(linked.serverUrl, "Linked server URL");
if (linked.token) headers.Authorization = `Bearer ${linked.token}`; 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)}`); throw new Error(`Unknown transfer origin: ${String(origin)}`);
@@ -890,6 +906,11 @@ function createLocalFileHandlers({
}; };
return { return {
[IPC.SET_API]: wrap(async (_event, apiPath) => {
setTransferApiPath(apiPath);
return {};
}),
[IPC.HOME]: wrap(async () => ({ [IPC.HOME]: wrap(async () => ({
home: os.homedir(), home: os.homedir(),
separator: path.sep, separator: path.sep,
@@ -1028,6 +1049,7 @@ module.exports = {
// exported for tests / reuse // exported for tests / reuse
createLocalFileHandlers, createLocalFileHandlers,
createTargetResolver, createTargetResolver,
setTransferApiPath,
publishDownload, publishDownload,
defaultPublishFs, defaultPublishFs,
assertWithinRoot, assertWithinRoot,
+32 -5
View File
@@ -630,7 +630,14 @@ const isolatedWindows = createIsolatedWindows({
// ctx.desktop.openIsolatedWindow is the one caller today. // ctx.desktop.openIsolatedWindow is the one caller today.
const BACKEND_REQUEST_HANDLERS = { const BACKEND_REQUEST_HANDLERS = {
"open-isolated-window": (payload) => isolatedWindows.open(payload), "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) => { "sync-proxy-config": (payload) => {
linkedServer.setLinkedServer(payload); linkedServer.setLinkedServer(payload);
return { success: true }; 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( ipcMain.handle(
"oidc-system-browser-auth", "external-browser-login",
async (_event, authUrl, callbackPort) => { async (_event, authUrl, callbackPort) => {
const http = require("http"); const http = require("http");
@@ -1666,7 +1673,7 @@ ipcMain.handle(
// Timeout after 5 minutes // Timeout after 5 minutes
timeout = setTimeout( timeout = setTimeout(
() => { () => {
fail(new Error("OIDC authentication timed out")); fail(new Error("External login timed out"));
}, },
5 * 60 * 1000, 5 * 60 * 1000,
); );
@@ -1868,8 +1875,28 @@ function getC2SRemoteBaseUrl() {
return serverUrl.replace(/\/$/, ""); 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() { 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:"); return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
} }
+8 -1
View File
@@ -53,6 +53,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
}, },
startC2SAutoStartTunnels: () => startC2SAutoStartTunnels: () =>
ipcRenderer.invoke("start-c2s-autostart-tunnels"), ipcRenderer.invoke("start-c2s-autostart-tunnels"),
setC2SRelayPath: (relayPath) =>
ipcRenderer.invoke("set-c2s-relay-path", relayPath),
onCloseActiveTab: (callback) => { onCloseActiveTab: (callback) => {
const listener = () => callback(); const listener = () => callback();
@@ -72,8 +74,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
timeoutMs, 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) => oidcSystemBrowserAuth: (authUrl, callbackPort) =>
ipcRenderer.invoke("oidc-system-browser-auth", authUrl, callbackPort), ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
openExternalEditor: (fileData) => openExternalEditor: (fileData) =>
ipcRenderer.invoke("open-external-editor", fileData), ipcRenderer.invoke("open-external-editor", fileData),
@@ -151,6 +156,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
open: (targetPath) => ipcRenderer.invoke("local-fs:open", targetPath), open: (targetPath) => ipcRenderer.invoke("local-fs:open", targetPath),
}, },
localTransfer: { localTransfer: {
setApiPath: (apiPath) =>
ipcRenderer.invoke("local-transfer:set-api", apiPath),
upload: (options) => ipcRenderer.invoke("local-transfer:upload", options), upload: (options) => ipcRenderer.invoke("local-transfer:upload", options),
download: (options) => download: (options) =>
ipcRenderer.invoke("local-transfer:download", options), ipcRenderer.invoke("local-transfer:download", options),
+15
View File
@@ -43,6 +43,21 @@ const pluginBoundary = {
ExportNamedDeclaration: (node) => check(node, node.source?.value), ExportNamedDeclaration: (node) => check(node, node.source?.value),
ExportAllDeclaration: (node) => check(node, node.source.value), ExportAllDeclaration: (node) => check(node, node.source.value),
ImportExpression: (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"], "ignoreBinaries": ["powershell"],
"ignoreDependencies": [ "ignoreDependencies": [
"@codemirror/autocomplete",
"@codemirror/commands",
"@codemirror/search",
"@codemirror/theme-one-dark", "@codemirror/theme-one-dark",
"@codemirror/view", "@codemirror/view",
"@electron/notarize", "@electron/notarize",
"@monaco-editor/react",
"@radix-ui/react-accordion",
"@radix-ui/react-alert-dialog", "@radix-ui/react-alert-dialog",
"@radix-ui/react-checkbox", "@radix-ui/react-checkbox",
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-label", "@radix-ui/react-label",
"@radix-ui/react-popover", "@radix-ui/react-popover",
"@radix-ui/react-progress",
"@radix-ui/react-scroll-area", "@radix-ui/react-scroll-area",
"@radix-ui/react-select", "@radix-ui/react-select",
"@radix-ui/react-separator",
"@radix-ui/react-slider", "@radix-ui/react-slider",
"@radix-ui/react-slot", "@radix-ui/react-slot",
"@radix-ui/react-switch", "@radix-ui/react-switch",
"@radix-ui/react-tabs", "@radix-ui/react-tabs",
"@radix-ui/react-tooltip",
"@types/better-sqlite3", "@types/better-sqlite3",
"@types/cookie-parser", "@types/cookie-parser",
"@types/cors", "@types/cors",
"@types/express", "@types/express",
"@types/guacamole-common-js",
"@types/js-yaml",
"@types/jsonwebtoken", "@types/jsonwebtoken",
"@types/multer", "@types/multer",
"@types/ssh2", "@types/ssh2",
"@uiw/codemirror-extensions-langs",
"@uiw/codemirror-theme-github",
"@uiw/react-codemirror", "@uiw/react-codemirror",
"@xterm/addon-clipboard", "@xterm/addon-clipboard",
"@xterm/addon-fit",
"@xterm/addon-unicode11",
"@xterm/addon-web-links",
"@xterm/xterm",
"axios", "axios",
"bcryptjs", "bcryptjs",
"better-sqlite3", "better-sqlite3",
@@ -73,30 +55,15 @@
"cmdk", "cmdk",
"cookie-parser", "cookie-parser",
"cors", "cors",
"cytoscape",
"drizzle-orm", "drizzle-orm",
"express", "express",
"guacamole-lite",
"guacamole-common-js",
"js-yaml",
"jsonwebtoken", "jsonwebtoken",
"jszip",
"multer", "multer",
"nanoid", "nanoid",
"i18next-browser-languagedetector", "i18next-browser-languagedetector",
"lucide-react", "lucide-react",
"motion", "motion",
"radix-ui", "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", "socks",
"ssh2", "ssh2",
"undici", "undici",
+662 -423
View File
File diff suppressed because it is too large. Load diff
+4 -35
View File
@@ -16,12 +16,12 @@
"scripts": { "scripts": {
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "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", "prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs", "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 .", "lint:fix": "eslint --fix .",
"type-check": "tsc -b --force", "type-check": "npm run build:sdk && tsc -b --force",
"test": "vitest run", "test": "vitest run",
"verify:dialect": "tsx scripts/verify-dialects.mjs", "verify:dialect": "tsx scripts/verify-dialects.mjs",
"test:watch": "vitest", "test:watch": "vitest",
@@ -49,13 +49,12 @@
"schema:check": "node scripts/generate-dialect-schema.cjs --check", "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", "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", "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", "build:plugins": "node scripts/build-plugins.cjs",
"test:plugins": "node scripts/run-plugin-tests.cjs", "test:plugins": "node scripts/run-plugin-tests.cjs",
"test:all": "npm run test && npm run test:plugins" "test:all": "npm run test && npm run test:plugins"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.120.0",
"@tanstack/react-virtual": "^3.14.13", "@tanstack/react-virtual": "^3.14.13",
"@types/compression": "^1.8.1", "@types/compression": "^1.8.1",
"@types/semver": "^7.8.0", "@types/semver": "^7.8.0",
@@ -70,9 +69,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"guacamole-lite": "^1.2.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"jszip": "^3.10.2",
"motion": "^13.1.1", "motion": "^13.1.1",
"multer": "^2.3.0", "multer": "^2.3.0",
"mysql2": "^3.23.4", "mysql2": "^3.23.4",
@@ -88,9 +85,6 @@
"ws": "^8.21.3" "ws": "^8.21.3"
}, },
"devDependencies": { "devDependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.1",
"@codemirror/search": "^6.7.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.12", "@codemirror/view": "^6.43.12",
"@commitlint/cli": "^21.2.2", "@commitlint/cli": "^21.2.2",
@@ -103,24 +97,17 @@
"@fontsource/fira-code": "^5.3.0", "@fontsource/fira-code": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/source-code-pro": "^5.3.0", "@fontsource/source-code-pro": "^5.3.0",
"@monaco-editor/react": "^4.7.0",
"@peculiar/x509": "^2.1.0", "@peculiar/x509": "^2.1.0",
"@radix-ui/react-accordion": "^1.2.20",
"@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-checkbox": "^1.3.11", "@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-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23", "@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-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7", "@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slider": "^1.4.7", "@radix-ui/react-slider": "^1.4.7",
"@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7", "@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21", "@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.2", "@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1", "@testing-library/jest-dom": "^7.0.1",
@@ -130,7 +117,6 @@
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/guacamole-common-js": "^1.5.5",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
@@ -140,18 +126,11 @@
"@types/ssh2": "^1.15.6", "@types/ssh2": "^1.15.6",
"@types/supertest": "^7.2.1", "@types/supertest": "^7.2.1",
"@types/ws": "^8.18.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", "@uiw/react-codemirror": "^4.25.11",
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.11", "@vitest/coverage-v8": "^4.1.11",
"@vitest/ui": "^4.1.11", "@vitest/ui": "^4.1.11",
"@xterm/addon-clipboard": "^0.2.0", "@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", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -164,7 +143,6 @@
"eslint-plugin-react-refresh": "^0.5.7", "eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.11.0", "globals": "^17.11.0",
"guacamole-common-js": "^1.5.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"i18next": "^26.4.2", "i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
@@ -176,17 +154,8 @@
"radix-ui": "^1.6.7", "radix-ui": "^1.6.7",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^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-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", "reflect-metadata": "^0.2.2",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"supertest": "^7.3.0", "supertest": "^7.3.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
+103
View File
@@ -1,5 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs"; import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
import { staticUrlImports } from "../lib/static-url-imports.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 }) { export async function build({ cwd }) {
const manifest = readManifest(cwd); const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd); const pluginId = manifest.id ?? path.basename(cwd);
const outDir = path.join(cwd, "dist"); const outDir = path.join(cwd, "dist");
applyPatches(cwd, pluginId);
fs.rmSync(outDir, { recursive: true, force: true }); fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: 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, "locales"), path.join(outDir, "locales"));
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations")); 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 { fileURLToPath } from "node:url";
import { readManifest } from "../lib/plugin-dir.mjs"; import { readManifest } from "../lib/plugin-dir.mjs";
import { LEGACY_TABLE_OWNERS as LEGACY_TABLES } from "../../dist/db.js"; 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 * 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(...validateMigrations(cwd, raw.id ?? path.basename(cwd)));
problems.push(...validateNativeDependencies(cwd, raw)); problems.push(...validateNativeDependencies(cwd, raw));
problems.push(...validatePackage(cwd, raw));
if (problems.length > 0) { if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`); 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)}`); 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 * 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 * the build's external declaration points at nothing node_modules can
@@ -106,16 +137,25 @@ function validateMigrations(cwd, pluginId) {
.filter((file) => file.endsWith(".sql")) .filter((file) => file.endsWith(".sql"))
.sort(); .sort();
byDialect.set(dialect, files); 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)) { if (!/^\d{4}_[a-z0-9_]+\.sql$/.test(file)) {
problems.push( problems.push(
`migrations/${dialect}/${file} must be named NNNN_name.sql, lower snake_case`, `migrations/${dialect}/${file} must be named NNNN_name.sql, lower snake_case`,
); );
} }
const sql = fs.readFileSync(path.join(dir, file), "utf8"); const sql = sqls[index];
for (const problem of findUnownedTableWrites(pluginId, sql, legacy)) { for (const problem of findUnownedTableWrites(
pluginId,
sql,
legacy,
indexes,
)) {
problems.push(`migrations/${dialect}/${file} ${problem}`); problems.push(`migrations/${dialect}/${file} ${problem}`);
} }
} }
+4 -1
View File
@@ -6,7 +6,8 @@
*/ */
import process from "node:process"; 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 { validate } from "./commands/validate.mjs";
import { test } from "./commands/test.mjs"; import { test } from "./commands/test.mjs";
import { pack } from "./commands/pack.mjs"; import { pack } from "./commands/pack.mjs";
@@ -15,6 +16,7 @@ import { migrations } from "./commands/migrations.mjs";
const COMMANDS = { const COMMANDS = {
build, build,
patch: async ({ cwd }) => applyPatches(cwd, readManifest(cwd).id),
validate, validate,
test, test,
pack, pack,
@@ -32,6 +34,7 @@ if (!command || command === "--help" || command === "-h") {
"Usage: termix-plugin <command>", "Usage: termix-plugin <command>",
"", "",
" build Bundle the plugin into dist/", " 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", " validate Check manifest.json and the files it names",
" test Run the plugin's vitest suite", " test Run the plugin's vitest suite",
" pack Write <id>-<version>.tmxplug of the built plugin [--out dir]", " 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 * 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. * 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". * Node builtins are external automatically through platform: "node".
*/ */
@@ -18,9 +22,6 @@ export const BACKEND_EXTERNALS = [
"multer", "multer",
"cookie-parser", "cookie-parser",
"axios", "axios",
"jszip",
"guacamole-lite",
"@anthropic-ai/sdk",
"drizzle-orm", "drizzle-orm",
"drizzle-orm/*", "drizzle-orm/*",
// Native image processing, loaded on first use by the terminal's image upload. // Native image processing, loaded on first use by the terminal's image upload.
@@ -1,12 +1,20 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const packageRoot = path.join( // Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
__dirname, // the package resolves the way the plugin's bundle will resolve it.
"..", function packageDir(name) {
"node_modules", const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
"guacamole-common-js", // 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 = [ const bundlePaths = [
path.join(packageRoot, "dist", "esm", "guacamole-common.js"), path.join(packageRoot, "dist", "esm", "guacamole-common.js"),
@@ -1,14 +1,20 @@
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const xtermDir = path.join( // Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
__dirname, // the package resolves the way the plugin's bundle will resolve it.
"..", function packageDir(name) {
"node_modules", const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
"@xterm", // Walked by hand: a package whose exports map hides package.json cannot be
"xterm", // found with require.resolve.
"lib", 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 // Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart // 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", "types": "./dist/frontend.d.ts",
"default": "./dist/frontend.js" "default": "./dist/frontend.js"
}, },
"./desktop": {
"types": "./dist/desktop.d.ts",
"default": "./dist/desktop.js"
},
"./manifest": { "./manifest": {
"types": "./dist/manifest.d.ts", "types": "./dist/manifest.d.ts",
"default": "./dist/manifest.js" "default": "./dist/manifest.js"
@@ -70,29 +74,46 @@
"default": "./dist/ssh-certs.js" "default": "./dist/ssh-certs.js"
}, },
"./tsconfig.plugin.json": "./tsconfig.plugin.json", "./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": [ "files": [
"dist", "dist",
"cli", "cli",
"schema", "schema",
"tsconfig.plugin.json", "tsconfig.plugin.json",
"README.md" "README.md",
"client.d.ts"
], ],
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/node": "^4.2.4",
"@tailwindcss/oxide": "^4.2.4",
"esbuild": "^0.28.1", "esbuild": "^0.28.1",
"semver": "^7.6.3", "semver": "^7.6.3",
"tailwindcss": "^4.2.4",
"tar": "^7.5.22" "tar": "^7.5.22"
}, },
"peerDependencies": { "peerDependencies": {
"drizzle-orm": "*", "@testing-library/react": "^16.3.3",
"ssh2": "*",
"better-sqlite3": "*", "better-sqlite3": "*",
"drizzle-orm": "*",
"i18next": "^26.4.2",
"react": "*", "react": "*",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.14",
"sonner": "^2.0.8",
"ssh2": "*",
"vitest": "*" "vitest": "*"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@@ -110,6 +131,9 @@
}, },
"vitest": { "vitest": {
"optional": true "optional": true
},
"@testing-library/react": {
"optional": true
} }
}, },
"publishConfig": { "publishConfig": {
+52 -12
View File
@@ -82,12 +82,12 @@
"termix": { "termix": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"description": "Display string only. Never enforced." "description": "Semver range of Termix releases the plugin runs on, e.g. \">=2.9.0\"."
}, },
"api": { "api": {
"type": "string", "type": "string",
"pattern": "^[0-9]+$", "minLength": 1,
"description": "Plugin SDK major version. The real compatibility gate." "description": "Semver range of the plugin API it needs, e.g. \"1\" or \"^1.2\"."
} }
} }
}, },
@@ -115,6 +115,8 @@
"notify:send", "notify:send",
"audit:read", "audit:read",
"desktop:window", "desktop:window",
"users:impersonate",
"db:core-refs",
"hosts:read", "hosts:read",
"db:own", "db:own",
"kv:own", "kv:own",
@@ -290,7 +292,9 @@
"id": { "id": {
"type": "string", "type": "string",
"pattern": "^[a-z][a-z0-9-]{0,31}$", "pattern": "^[a-z][a-z0-9-]{0,31}$",
"not": { "enum": ["ssh"] } "not": {
"enum": ["ssh"]
}
}, },
"credentialFields": { "credentialFields": {
"type": "array", "type": "array",
@@ -311,7 +315,9 @@
] ]
} }
}, },
"secret": { "type": "boolean" } "secret": {
"type": "boolean"
}
} }
} }
}, },
@@ -323,7 +329,9 @@
"hostLoginFallback": { "hostLoginFallback": {
"type": "array", "type": "array",
"uniqueItems": true, "uniqueItems": true,
"items": { "enum": ["username", "password"] } "items": {
"enum": ["username", "password"]
}
} }
} }
} }
@@ -352,17 +360,28 @@
"type": "object", "type": "object",
"propertyNames": { "propertyNames": {
"pattern": "^[a-zA-Z][a-zA-Z0-9]{0,31}$", "pattern": "^[a-zA-Z][a-zA-Z0-9]{0,31}$",
"not": { "const": "type" } "not": {
"const": "type"
}
}, },
"additionalProperties": { "additionalProperties": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["type"], "required": ["type"],
"properties": { "properties": {
"type": { "enum": ["string", "boolean"] }, "type": {
"required": { "type": "boolean" }, "enum": ["string", "boolean"]
"pattern": { "type": "string" }, },
"maxLength": { "type": "integer", "minimum": 1 }, "required": {
"type": "boolean"
},
"pattern": {
"type": "string"
},
"maxLength": {
"type": "integer",
"minimum": 1
},
"syncEntity": { "syncEntity": {
"type": "string", "type": "string",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$" "pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
@@ -894,6 +913,15 @@
"ownerOnly": { "ownerOnly": {
"type": "boolean", "type": "boolean",
"description": "Only the host's owner may change it." "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", "type": "string",
"pattern": "^(/[A-Za-z0-9_.-]+)+$" "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. * to query through `db`. Both require db:own.
* *
* Scoped by name: every table carries the p_<id>_ prefix, and `refs` exposes * 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 * users, ssh_data, roles and user_roles (behind db:core-refs) so a plugin can
* them without being able to write them. In-process code could reach around * join against them. In-process code could reach around
* all of this. The capability, the prefix, lint and review are the contract, * all of this. The capability, the prefix, lint and review are the contract,
* not a sandbox. * not a sandbox.
*/ */
@@ -106,7 +106,10 @@ export interface PluginDatabase {
define: <T = unknown>(definition: PluginTableDefinition) => Promise<T>; define: <T = unknown>(definition: PluginTableDefinition) => Promise<T>;
/** Drizzle handle, scoped to this plugin's tables. */ /** Drizzle handle, scoped to this plugin's tables. */
client: <T = unknown>() => Promise<T>; 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>; refs: <T = unknown>() => Promise<T>;
/** /**
* Flushes writes to disk. Call it after every write: on SQLite the database * Flushes writes to disk. Call it after every write: on SQLite the database
@@ -235,6 +238,11 @@ export interface PluginSync {
registerEntity: (entity: SyncEntityRegistration) => void; 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 { export interface PluginRegistry {
provide: <T>(key: string, value: T) => void; provide: <T>(key: string, value: T) => void;
consume: <T>(key: string) => T | undefined; consume: <T>(key: string) => T | undefined;
@@ -252,7 +260,11 @@ export interface PluginServices {
implementation: T, implementation: T,
options?: { name?: string }, options?: { name?: string },
) => void; ) => 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>( get: <T extends object>(
service: string, service: string,
options?: { userId?: string; provider?: string }, options?: { userId?: string; provider?: string },
@@ -1340,6 +1352,12 @@ export interface PluginLoginInstance {
* route ("oidc", "github", "google", "ldap"). Leave unset otherwise. * route ("oidc", "github", "google", "ldap"). Leave unset otherwise.
*/ */
type?: string; 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 { export interface PluginLoginMethod {
@@ -1468,7 +1486,13 @@ export interface PluginOpenIsolatedWindowRequest {
* Electron only: rejects when the server is not running embedded in the * Electron only: rejects when the server is not running embedded in the
* desktop app. Needs desktop:window. * 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; host: string;
port?: number; port?: number;
username?: string; username?: string;
@@ -1480,12 +1504,11 @@ export interface PluginDesktop {
request: PluginOpenIsolatedWindowRequest, request: PluginOpenIsolatedWindowRequest,
) => Promise<{ success: true }>; ) => Promise<{ success: true }>;
/** /**
* Opens the operating system's own RDP client (mstsc on Windows) for a * Opens the operating system's own client for a protocol. The password is
* host. The password is never passed; the client asks for it. Windows * never passed; the client asks for it. Desktop app only.
* desktop app only.
*/ */
launchNativeRdp: ( launchExternalClient: (
request: PluginNativeRdpRequest, request: PluginExternalClientRequest,
) => Promise<{ success: boolean; error?: string }>; ) => Promise<{ success: boolean; error?: string }>;
/** Whether the server runs embedded in the desktop app, so the calls above can work. */ /** Whether the server runs embedded in the desktop app, so the calls above can work. */
available: () => boolean; available: () => boolean;
@@ -1942,8 +1965,8 @@ export interface PluginContext {
* no request behind it. Always audited, and every core API inside still * no request behind it. Always audited, and every core API inside still
* applies that user's RBAC. * applies that user's RBAC.
* *
* This is the only way a plugin can name a user. Nothing else trusts a user * Needs users:impersonate. That and a `userId` option on services.get or
* id that came from plugin code. * secrets.getShared are the only ways a plugin can name a user.
*/ */
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>; 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("ssh:connect", "high"),
entry("process:spawn", "high"), entry("process:spawn", "high"),
entry("users:write", "high"), entry("users:write", "high"),
entry("users:impersonate", "high"),
entry("db:core-refs", "high"),
entry("auth:provide", "high"), entry("auth:provide", "high"),
entry("device:serial", "high"), entry("device:serial", "high"),
entry("notify:hub", "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. */ /** What a migration may CREATE, ALTER or DROP. Anything else is refused. */
const TABLE_OBJECTS = new Set(["TABLE", "VIEW"]); 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 = [ const CREATE_MODIFIERS = [
"OR", "OR",
@@ -460,9 +488,27 @@ export function findUnownedTableWrites(
pluginId: string, pluginId: string,
sql: string, sql: string,
ownedLegacy: ReadonlySet<string> = new Set(), ownedLegacy: ReadonlySet<string> = new Set(),
ownedIndexes: ReadonlySet<string> = new Set(),
): string[] { ): string[] {
const prefix = tablePrefix(pluginId); const prefix = tablePrefix(pluginId);
const problems: string[] = []; 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)) { for (const statement of splitStatements(sql)) {
const tokens = tokenize(statement); const tokens = tokenize(statement);
@@ -507,16 +553,47 @@ export function findUnownedTableWrites(
continue; 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") { if (first === "CREATE" || first === "ALTER" || first === "DROP") {
let at = skip(1, CREATE_MODIFIERS); let at = skip(1, CREATE_MODIFIERS);
const object = upper(at); const object = upper(at);
if (object === "INDEX") { if (object === "INDEX") {
// The index name is free; the table after ON is what it writes to. // The index name is free; the table after ON is what it writes to.
const on = find("ON", at); 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; continue;
} }
if (HARMLESS_OBJECTS.has(object)) continue;
if (!TABLE_OBJECTS.has(object)) { if (!TABLE_OBJECTS.has(object)) {
problems.push( problems.push(
`${first} ${object || tokens[at]?.value || ""} is not allowed in a plugin migration`.trim(), `${first} ${object || tokens[at]?.value || ""} is not allowed in a plugin migration`.trim(),
@@ -565,6 +642,14 @@ export function findUnownedTableWrites(
} }
if (first === "UPDATE") { 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( table(
skip(1, [ skip(1, [
"OR", "OR",
@@ -582,12 +667,51 @@ export function findUnownedTableWrites(
if (first === "DELETE") { if (first === "DELETE") {
const from = find("FROM"); const from = find("FROM");
if (from > 0) table(skip(from + 1, ["ONLY"])); if (from < 0) {
else problems.push("DELETE without FROM is not allowed"); 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; 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( problems.push(
`"${tokens[0].value}" statements are not allowed in a plugin migration`, `"${tokens[0].value}" statements are not allowed in a plugin migration`,
); );
@@ -596,3 +720,44 @@ export function findUnownedTableWrites(
return problems; 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; instanceId?: string;
/** A backend session a session tab should reattach to. */ /** A backend session a session tab should reattach to. */
restoredSessionId?: string | null; restoredSessionId?: string | null;
/** A live shared session this tab joins instead of connecting its own. */
joinSharedSessionId?: string | null;
joinShareId?: string | null;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -192,6 +189,11 @@ export interface ShellApi {
options?: OpenTabOptions, options?: OpenTabOptions,
) => void; ) => void;
openSingletonTab: (type: string, 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; closeTab: (tabId: string) => void;
renameTab: (tabId: string, label: string) => void; renameTab: (tabId: string, label: string) => void;
/** Saves a quick-connect tab's host as a real host. Absent in some shells. */ /** 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< export interface TabsApi extends Pick<
ShellApi, ShellApi,
"openTab" | "openSingletonTab" | "closeTab" | "openRailView" "openTab" | "openSingletonTab" | "connectHost" | "closeTab" | "openRailView"
> { > {
/** The current tabs and split layout, or null before the shell mounts. */ /** The current tabs and split layout, or null before the shell mounts. */
getLayout: () => ShellLayout | null; getLayout: () => ShellLayout | null;
@@ -1085,6 +1087,15 @@ export interface PluginCoreApi {
listHosts: (pluginId: string) => Promise<PluginHostRecord[]>; listHosts: (pluginId: string) => Promise<PluginHostRecord[]>;
/** The user's stored credentials, without their secrets. */ /** The user's stored credentials, without their secrets. */
listCredentials: () => Promise<PluginCredentialSummary[]>; 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. */ /** A stored credential as a picker needs it. Secrets never leave core. */
@@ -1114,8 +1125,17 @@ export interface HostStatusInfo {
let host: PluginHostBridge | null = null; 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 { 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; host = bridge;
} }
@@ -1211,6 +1231,21 @@ export function logActivity(
return requireHost().core.logActivity(type, hostId, hostName); 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( export function getHostPassword(
hostId: number, hostId: number,
field: "password" | "sudoPassword", field: "password" | "sudoPassword",
@@ -1583,3 +1618,6 @@ export function useConnectionRetry({
export type { PluginManifest } from "./manifest.js"; export type { PluginManifest } from "./manifest.js";
export type { ReactNode }; 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"; import { isKnownCapability } from "./capabilities.js";
/** /**
* The SDK major version this build implements. engine.api is the real * The plugin API this build implements. A minor bump adds to the API and
* compatibility gate; engine.termix is a display string and is never enforced. * 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 = [ export const PLUGIN_CATEGORIES = [
"Terminal", "Terminal",
@@ -59,14 +89,12 @@ export const RESERVED_PLUGIN_IDS: readonly string[] = [
/** A preset key is a plain property name, never a prototype one. */ /** 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 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 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". */ /** Service names are dotted, e.g. "ssh.transport". */
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/; const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/; const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
/** Action ids name a frontend function, so segments may be camelCase. */ /** Action ids name a frontend function, so segments may be camelCase. */
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/; const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
const HANDLER_PATTERN = /^[A-Za-z_$][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, * A plugin-relative permission name. Unlike a full id one segment is fine,
* and underscores are allowed in the first segment too ("manage_providers"). * and underscores are allowed in the first segment too ("manage_providers").
@@ -98,9 +126,12 @@ export interface PluginAuthor {
} }
export interface PluginEngine { 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; 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; api: string;
} }
@@ -257,6 +288,11 @@ export interface PluginSettingsField {
shareRead?: "connect" | "view" | "edit" | "manage"; shareRead?: "connect" | "view" | "edit" | "manage";
/** Host scope only: only the host's owner may change it. */ /** Host scope only: only the host's owner may change it. */
ownerOnly?: boolean; 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 * 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 * 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. * off and a desktop can list them in its sync settings.
*/ */
syncEntities?: string[]; 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 * Keybinding actions this plugin adds to Appearance > Keybindings, with
* the parameters a saved binding carries. Core validates saved bindings * the parameters a saved binding carries. Core validates saved bindings
@@ -562,6 +605,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"uiPresets", "uiPresets",
"auth", "auth",
"syncEntities", "syncEntities",
"uses",
"keybindingActions", "keybindingActions",
"protocols", "protocols",
]); ]);
@@ -584,6 +628,7 @@ const ALLOWED_SETTINGS_FIELD = [
"defaultFrom", "defaultFrom",
"shareRead", "shareRead",
"ownerOnly", "ownerOnly",
"secretKeys",
]; ];
const SHARE_LEVELS = ["connect", "view", "edit", "manage"]; const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
@@ -738,9 +783,20 @@ function validateEngine(engine: unknown, errors: string[]): void {
} }
rejectUnknown(engine, ["termix", "api"], '"engine"', errors); rejectUnknown(engine, ["termix", "api"], '"engine"', errors);
requireString(engine.termix, "engine.termix", 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( 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[]) { function validateNameList(value: unknown, where: string, errors: string[]) {
if (!Array.isArray(value) || value.length === 0) { if (!Array.isArray(value) || value.length === 0) {
errors.push(`${where} must be a non-empty array`); errors.push(`${where} must be a non-empty array`);
@@ -1025,6 +1103,9 @@ function validateContributes(
if (contributes.syncEntities !== undefined) { if (contributes.syncEntities !== undefined) {
validateSyncEntities(contributes.syncEntities, errors); validateSyncEntities(contributes.syncEntities, errors);
} }
if (contributes.uses !== undefined) {
validateUses(contributes.uses, errors);
}
validatePermissions(contributes.permissions, pluginId, errors); validatePermissions(contributes.permissions, pluginId, errors);
validateActions(contributes.actions, errors); validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors); validateActionSlots(contributes.actionSlots, errors);
@@ -1534,6 +1615,18 @@ function validateSettings(settings: unknown, errors: string[]): void {
if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") { if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") {
errors.push(`${fieldAt}.ownerOnly must be a 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) { for (const scope of ["admin", "user"] as const) {
@@ -1957,10 +2050,10 @@ export function parseManifest(raw: unknown): ParsedManifest {
const manifest = raw as PluginManifest; const manifest = raw as PluginManifest;
if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) { if (!isApiCompatible(manifest.engine.api)) {
return { return {
errors: [ 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 { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js"; import type { PluginDatabase } from "./backend.js";
import type { import type {
PluginNativeRdpRequest, PluginExternalClientRequest,
PluginProtocolTarget, PluginProtocolTarget,
PluginHostStatusEntry, PluginHostStatusEntry,
PluginNotification, PluginNotification,
@@ -297,8 +297,8 @@ export interface FakePluginContext {
title?: string; title?: string;
ignoreCert?: boolean; ignoreCert?: boolean;
}>; }>;
/** Every ctx.desktop.launchNativeRdp call, in order. */ /** Every ctx.desktop.launchExternalClient call, in order. */
nativeRdpLaunches: PluginNativeRdpRequest[]; externalClientLaunches: PluginExternalClientRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */ /** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>; credentialReads: Array<{ hostId: number; protocol: string }>;
/** Every ctx.credentials.createSshKey call, with the id it answered. */ /** Every ctx.credentials.createSshKey call, with the id it answered. */
@@ -361,6 +361,11 @@ export interface FakePluginContext {
runScheduled: () => Promise<void>; runScheduled: () => Promise<void>;
/** Changes the acting user, as core's request middleware would. */ /** Changes the acting user, as core's request middleware would. */
setActor: (userId: string | undefined) => void; 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>". */ /** Provided or seeded services, by name or "<service>#<provider>". */
services: Map<string, object>; services: Map<string, object>;
} }
@@ -497,7 +502,8 @@ export function createFakeContext(
const sshConnections: FakePluginContext["sshConnections"] = []; const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = []; const hostShares: FakePluginContext["hostShares"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = []; const desktopWindows: FakePluginContext["desktopWindows"] = [];
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = []; const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
[];
const credentialReads: FakePluginContext["credentialReads"] = []; const credentialReads: FakePluginContext["credentialReads"] = [];
const createdSshKeys: FakePluginContext["createdSshKeys"] = []; const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = []; const notifications: FakePluginContext["notifications"] = [];
@@ -695,10 +701,12 @@ export function createFakeContext(
registry: { registry: {
provide: (key, value) => { provide: (key, value) => {
assertOwnRegistryKey(pluginId, key);
registryProviders.set(key, value); registryProviders.set(key, value);
}, },
consume: (key) => registryProviders.get(key) as never, consume: (key) => registryProviders.get(key) as never,
revoke: (key, value) => { revoke: (key, value) => {
assertOwnRegistryKey(pluginId, key);
if (!registryProviders.has(key)) return false; if (!registryProviders.has(key)) return false;
if (value !== undefined && registryProviders.get(key) !== value) { if (value !== undefined && registryProviders.get(key) !== value) {
return false; return false;
@@ -1128,8 +1136,8 @@ export function createFakeContext(
desktopWindows.push(request); desktopWindows.push(request);
return { success: true }; return { success: true };
}, },
launchNativeRdp: async (request) => { launchExternalClient: async (request) => {
nativeRdpLaunches.push(request); externalClientLaunches.push(request);
return { success: true }; return { success: true };
}, },
available: () => options.desktopAvailable ?? false, available: () => options.desktopAvailable ?? false,
@@ -1309,7 +1317,7 @@ export function createFakeContext(
hostShares, hostShares,
auth, auth,
desktopWindows, desktopWindows,
nativeRdpLaunches, externalClientLaunches,
credentialReads, credentialReads,
createdSshKeys, createdSshKeys,
notifications, notifications,
@@ -1337,6 +1345,15 @@ export function createFakeContext(
setActor: (userId) => { setActor: (userId) => {
actor = userId; actor = userId;
}, },
actAs: async (userId, fn) => {
const previous = actor;
actor = userId;
try {
return await fn();
} finally {
actor = previous;
}
},
services, services,
}; };
} }
@@ -1477,6 +1494,11 @@ export function createMockCtx(
const gatedCtx: PluginContext = { const gatedCtx: PluginContext = {
...ctx, ...ctx,
asUser: async (userId, fn) => {
require("users:impersonate");
return ctx.asUser(userId, fn);
},
db: { db: {
define: async (definition) => { define: async (definition) => {
require("db:own"); require("db:own");
@@ -1487,7 +1509,7 @@ export function createMockCtx(
return guardedDb.client(); return guardedDb.client();
}, },
refs: async () => { refs: async () => {
require("db:own"); require("db:core-refs");
return guardedDb.refs(); return guardedDb.refs();
}, },
persist: async (persistOptions) => { persist: async (persistOptions) => {
@@ -1745,9 +1767,9 @@ export function createMockCtx(
require("desktop:window"); require("desktop:window");
return ctx.desktop.openIsolatedWindow(request); return ctx.desktop.openIsolatedWindow(request);
}, },
launchNativeRdp: async (request) => { launchExternalClient: async (request) => {
require("desktop:window"); require("desktop:window");
return ctx.desktop.launchNativeRdp(request); return ctx.desktop.launchExternalClient(request);
}, },
available: () => ctx.desktop.available(), available: () => ctx.desktop.available(),
}, },
@@ -1882,7 +1904,7 @@ export interface TestDbOptions {
* Runs after the core stub tables exist and before the plugin's migrations, * Runs after the core stub tables exist and before the plugin's migrations,
* e.g. to create the legacy table an adoption migration renames. * 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. */ /** Skip applying the migrations, to apply them by hand later. */
skipMigrations?: boolean; skipMigrations?: boolean;
} }
@@ -2046,6 +2068,8 @@ export interface RenderWithAppOptions {
ready?: boolean; ready?: boolean;
/** Stands in for app.api and usePluginApi(), e.g. a stub of the routes. */ /** Stands in for app.api and usePluginApi(), e.g. a stub of the routes. */
api?: import("./frontend.js").PluginApiClient; 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. */ /** 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; const host = (await import(/* @vite-ignore */ TEST_HOST)) as PluginTestHost;
return host.renderPlugin(plugin, options); 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: () => ({ useTabs: () => ({
openTab: noop, openTab: noop,
openSingletonTab: noop, openSingletonTab: noop,
connectHost: noop,
closeTab: noop, closeTab: noop,
getLayout: () => null, getLayout: () => null,
applyLayout: async () => ({ skipped: [] }), applyLayout: async () => ({ skipped: [] }),
@@ -142,6 +143,9 @@ const offline = () =>
setClientPreference: () => {}, setClientPreference: () => {},
listHosts: async () => [], listHosts: async () => [],
listCredentials: 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"; 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 * Inside the repo the SDK's browser entries and the test host resolve to
* (the legacy debt A7 and D1 remove), so the preset has to resolve it. A * core's source, so a test exercises the real registries. Outside it they
* plugin developed outside this repo has no src/ui to point at, which is * resolve to the builds the SDK ships in dist/host.
* exactly right: the alias stops resolving and the import fails loudly.
*/ */
function findRepoRoot(startDir: string): string | null { function findRepoRoot(startDir: string): string | null {
let dir = startDir; let dir = startDir;
@@ -83,7 +82,13 @@ export function pluginVitestConfig(
"@/types": path.join(repoRoot, "src", "types"), "@/types": path.join(repoRoot, "src", "types"),
"@": path.join(repoRoot, "src", "ui"), "@": 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, ...options.alias,
}; };
@@ -112,6 +117,10 @@ export function pluginVitestConfig(
environment: "jsdom", environment: "jsdom",
globals: true, globals: true,
setupFiles: [setupFile], 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}"], include: ["tests/frontend/**/*.test.{ts,tsx}"],
}, },
}, },
+4 -1
View File
@@ -5,6 +5,8 @@
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"jsx": "react-jsx", "jsx": "react-jsx",
"allowImportingTsExtensions": true,
"types": ["node", "vitest/globals"],
"skipLibCheck": true, "skipLibCheck": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
@@ -24,5 +26,6 @@
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false,
"allowUnusedLabels": true, "allowUnusedLabels": true,
"allowUnreachableCode": 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": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": { "dependencies": {
"acme-client": "^5.4.0" "acme-client": "^5.4.0"
}, },
"devDependencies": { "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", "network:outbound",
"notify:send", "notify:send",
"network:serve", "network:serve",
"ui:surface" "ui:surface",
"users:impersonate"
], ],
"optionalDependencies": { "optionalDependencies": {
"workspaces": "^1.0.0", "workspaces": "^1.0.0",
@@ -185,7 +186,8 @@
} }
] ]
} }
} },
"uses": ["terminal.sidePanel", "terminal.toolbar"]
}, },
"locales": "locales", "locales": "locales",
"platforms": ["linux", "win32", "darwin"] "platforms": ["linux", "win32", "darwin"]
+24 -2
View File
@@ -6,10 +6,32 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "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": { "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 () => { it("passes an opted-in user through with their access attached", async () => {
const next = vi.fn(); const next = vi.fn();
const req: { aiAccess?: unknown } = {}; const req: Parameters<ReturnType<typeof createAiGate>>[0] = {};
await createAiGate( await createAiGate(
settings({ globallyEnabled: true }, { enabled: true }), settings({ globallyEnabled: true }, { enabled: true }),
() => "user-1", () => "user-1",
+18 -3
View File
@@ -6,14 +6,29 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": { "dependencies": {
"lucide-react": "^1.33.0",
"nodemailer": "^10.0.10" "nodemailer": "^10.0.10"
}, },
"devDependencies": { "devDependencies": {
"@termix/plugin-sdk": "*", "@termix/plugin-sdk": "^1.0.0",
"@types/nodemailer": "^8.0.2" "@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:outbound",
"network:serve", "network:serve",
"settings:read-core", "settings:read-core",
"ui:surface" "ui:surface",
"users:impersonate"
], ],
"optionalDependencies": { "optionalDependencies": {
"fleets": "^1.0.0", "fleets": "^1.0.0",
@@ -119,7 +120,8 @@
], ],
"http": { "http": {
"legacyPaths": ["/automations/webhook"] "legacyPaths": ["/automations/webhook"]
} },
"uses": ["alerts.openChannels"]
}, },
"locales": "locales", "locales": "locales",
"platforms": ["linux", "win32", "darwin"], "platforms": ["linux", "win32", "darwin"],
+21 -2
View File
@@ -6,10 +6,29 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": { "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"), capabilities: manifest.capabilities.filter((c) => c !== "notify:send"),
}); });
await expect( await expect(
withoutNotify.ctx.notify.send([1], { title: "t", body: "b" }), withoutNotify.ctx.notify.send({ title: "t", body: "b" } as never),
).rejects.toBeInstanceOf(PluginCapabilityError); ).rejects.toBeInstanceOf(PluginCapabilityError);
}); });
@@ -99,7 +99,7 @@ describe("onMetrics", () => {
}); });
expect(run).toHaveBeenCalledTimes(1); expect(run).toHaveBeenCalledTimes(1);
expect(run.mock.calls[0][0]).toMatchObject({ expect((run.mock.calls[0] as unknown[])[0]).toMatchObject({
triggerType: "metric_threshold", triggerType: "metric_threshold",
triggerHostId: 7, triggerHostId: 7,
}); });
+4 -2
View File
@@ -20,7 +20,8 @@
"ssh:connect", "ssh:connect",
"network:serve", "network:serve",
"ui:surface", "ui:surface",
"events:core" "events:core",
"users:impersonate"
], ],
"provides": [ "provides": [
{ {
@@ -93,7 +94,8 @@
"viewMode": "detail", "viewMode": "detail",
"containerLayout": "table" "containerLayout": "table"
} }
} },
"uses": ["homepage.widgets", "terminal.resolveTheme"]
}, },
"locales": "locales", "locales": "locales",
"platforms": ["linux", "win32", "darwin"] "platforms": ["linux", "win32", "darwin"]
+35 -2
View File
@@ -6,10 +6,43 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "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": { "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 type { ComponentType } from "react";
import { Box } from "lucide-react"; import { Box } from "lucide-react";
import type { import type {
+6 -2
View File
@@ -1,7 +1,8 @@
{ {
"nav": { "nav": {
"files": "Files", "files": "Files",
"sftp": "SFTP" "sftp": "SFTP",
"openFileManager": "Open File Manager"
}, },
"permissions": { "permissions": {
"use": { "use": {
@@ -520,6 +521,9 @@
"transferError": "Transfer failed", "transferError": "Transfer failed",
"transferFailedRetryHint": "Partial data was kept on the destination. Retry will resume when the connection is back.", "transferFailedRetryHint": "Partial data was kept on the destination. Retry will resume when the connection is back.",
"transferPartialHint": "Could not transfer: {{paths}}", "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", "network:serve",
"ui:surface", "ui:surface",
"events:core", "events:core",
"kv:own" "kv:own",
"users:impersonate"
], ],
"provides": [ "provides": [
{ {
@@ -108,7 +109,19 @@
"key": "enableFileManager", "key": "enableFileManager",
"labelKey": "hostCapability.label", "labelKey": "hostCapability.label",
"editorTab": "plugins" "editorTab": "plugins"
} },
"uiPresets": {
"simple": {
"viewMode": "grid"
},
"balanced": {
"viewMode": "grid"
},
"advanced": {
"viewMode": "list"
}
},
"uses": ["homepage.widgets", "host-metrics.disk", "terminal.open"]
}, },
"locales": "locales", "locales": "locales",
"platforms": ["linux", "win32", "darwin"] "platforms": ["linux", "win32", "darwin"]
+48 -2
View File
@@ -6,10 +6,56 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "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": { "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"; } from "@termix/plugin-sdk/ui";
import { ConnectionLogPanel } from "@termix/plugin-sdk/ui"; import { ConnectionLogPanel } from "@termix/plugin-sdk/ui";
import { import {
usePluginUiPreferences,
invokeAction, invokeAction,
useConnectionRetry, useConnectionRetry,
logActivity, logActivity,
@@ -177,25 +178,32 @@ function FileManagerContent({
const [isReconnecting, setIsReconnecting] = useState<boolean>(false); const [isReconnecting, setIsReconnecting] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0); const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
const [viewMode, setViewMode] = useState<"grid" | "list">(() => { // An interface preset sets this too, so it is a plugin UI preference.
const saved = localStorage.getItem("fileManagerViewMode"); const { values: fmPrefs, set: setFmPref } = usePluginUiPreferences<{
return saved === "grid" || saved === "list" ? saved : "grid"; 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">(() => const [density, setDensity] = useState<"comfortable" | "compact">(() =>
localStorage.getItem("fileManagerDensity") === "compact" localStorage.getItem("fileManagerDensity") === "compact"
? "compact" ? "compact"
: "comfortable", : "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 [sortBy, setSortBy] = useState<"name" | "modified" | "size">(() => {
const saved = localStorage.getItem("fileManagerSortBy"); const saved = localStorage.getItem("fileManagerSortBy");
return saved === "name" || saved === "modified" || saved === "size" return saved === "name" || saved === "modified" || saved === "size"
@@ -3328,10 +3336,6 @@ function FileManagerContent({
} }
}, [currentHost?.id]); }, [currentHost?.id]);
useEffect(() => {
localStorage.setItem("fileManagerViewMode", viewMode);
}, [viewMode]);
useEffect(() => { useEffect(() => {
localStorage.setItem("fileManagerDensity", density); localStorage.setItem("fileManagerDensity", density);
}, [density]); }, [density]);
@@ -15,7 +15,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui"; 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; const VIEWPORT_PADDING = 16;
@@ -28,7 +28,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { cn } from "@termix/plugin-sdk/ui"; import { cn } from "@termix/plugin-sdk/ui";
import { Button, Input } 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 { import {
createLocalFile, createLocalFile,
createLocalFolder, createLocalFolder,
@@ -9,10 +9,11 @@
// configured Remote Sync server) and the main process resolves the rest, so a // configured Remote Sync server) and the main process resolves the rest, so a
// compromised renderer cannot turn the transfer bridge into an HTTP client. // compromised renderer cannot turn the transfer bridge into an HTTP client.
import type { import {
LocalTransferOrigin, getLocalAuthToken,
LocalTransferProgress, type LocalTransferOrigin,
} from "@termix/plugin-sdk/ui"; type LocalTransferProgress,
} from "@termix/plugin-sdk/frontend";
import { getSessionOrigin } from "./client"; import { getSessionOrigin } from "./client";
import { getDeviceId } from "@termix/plugin-sdk/ui"; import { getDeviceId } from "@termix/plugin-sdk/ui";
@@ -67,11 +68,7 @@ function transferOriginFor(sessionId: string): LocalTransferOrigin {
*/ */
function localAuthTokenFor(origin: LocalTransferOrigin): string | undefined { function localAuthTokenFor(origin: LocalTransferOrigin): string | undefined {
if (origin !== "local") return undefined; if (origin !== "local") return undefined;
try { return getLocalAuthToken() ?? undefined;
return localStorage.getItem("jwt") || undefined;
} catch {
return undefined;
}
} }
export function createLocalTransferId(prefix: string): string { export function createLocalTransferId(prefix: string): string {
@@ -3,7 +3,7 @@ import { createElement } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { FileItem } from "../host-types"; 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 { useSettings } from "@termix/plugin-sdk/frontend";
import { import {
cancelLocalTransfer, cancelLocalTransfer,
+22 -3
View File
@@ -1,6 +1,6 @@
import { fileManagerHostSetting } from "./host-settings"; import { fileManagerHostSetting } from "./host-settings";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { FolderSearch, ArrowLeftRight } from "lucide-react"; import { FolderOpen, FolderSearch, ArrowLeftRight } from "lucide-react";
import { import {
invokeAction, invokeAction,
type PluginHostRecord, type PluginHostRecord,
@@ -70,8 +70,14 @@ function openEditorAction(
app.tabs.openTab(host, "files", { data: { initialFilePath: filePath } }); 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 { export function activate(app: TermixApp): void {
setFileManagerApp(app); setFileManagerApp(app);
if (typeof window !== "undefined") {
void window.electronAPI?.localTransfer?.setApiPath?.(TRANSFER_API_PATH);
}
app.onDispose(() => setFileManagerApp(null)); app.onDispose(() => setFileManagerApp(null));
app.registerTab("files", FilesTab as unknown as ComponentType<TabProps>, { app.registerTab("files", FilesTab as unknown as ComponentType<TabProps>, {
icon: FolderSearch, icon: FolderSearch,
@@ -135,8 +141,21 @@ export function activate(app: TermixApp): void {
const openHost = ((host: PluginHostRecord | null, path?: string) => const openHost = ((host: PluginHostRecord | null, path?: string) =>
openHostAction(app, host, path)) as never; openHostAction(app, host, path)) as never;
app.registerAction("files.openHost", openHost); app.registerAction("files.openHost", openHost);
// The shell's "open in file manager" tab button asks for this. // "Open File Manager" in the tab bar's menu, for a terminal tab. The
app.registerAction("host.openFiles", openHost); // 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", (( app.registerAction("files.openEditor", ((
host: PluginHostRecord | null, host: PluginHostRecord | null,
filePath: string, filePath: string,
@@ -8,7 +8,7 @@ import type {
LocalFsResult, LocalFsResult,
LocalTrashResult, LocalTrashResult,
LocalWalkResult, LocalWalkResult,
} from "@termix/plugin-sdk/ui"; } from "@termix/plugin-sdk/frontend";
import { isElectron } from "@termix/plugin-sdk/ui"; import { isElectron } from "@termix/plugin-sdk/ui";
export function isLocalFileBrowserAvailable(): boolean { export function isLocalFileBrowserAvailable(): boolean {
@@ -1,7 +1,7 @@
// Pure helpers shared by the dual-pane (local <-> remote) file manager code. // Pure helpers shared by the dual-pane (local <-> remote) file manager code.
// Kept free of React/DOM so they can be unit tested directly. // 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. */ /** Custom MIME type carried by drags that originate in the local pane. */
export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files"; 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> (req: Request, res: Response) => Promise<unknown>
>(); >();
registerFileDownloadRoutes( registerFileDownloadRoutes(
{ post: (path, handler) => routes.set(path, handler) } as Express, {
post: (path, handler) => routes.set(path, handler),
} as unknown as Express,
{ {
ctx: { ctx: {
currentActor: () => "user", currentActor: () => "user",
@@ -6,7 +6,7 @@ import {
LOCAL_FILES_DRAG_MIME, LOCAL_FILES_DRAG_MIME,
REMOTE_FILES_DRAG_MIME, REMOTE_FILES_DRAG_MIME,
} from "../../src/frontend/local-transfer-utils"; } 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. // jsdom has no layout, so render every row instead of a virtual window.
vi.mock("@tanstack/react-virtual", () => ({ vi.mock("@tanstack/react-virtual", () => ({
@@ -57,12 +57,15 @@ describe(`${manifest.id} activate`, () => {
expect(rendered.registered.hostEditorSections()).toEqual([]); 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 }); rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.actions().sort()).toEqual([ expect(rendered.registered.actions().sort()).toEqual([
"file-manager.openFromTab",
"files.openEditor", "files.openEditor",
"files.openHost", "files.openHost",
"host.openFiles", ]);
expect(rendered.registered.slot("tab.menu")).toEqual([
"file-manager.openFromTab",
]); ]);
}); });
@@ -25,7 +25,7 @@ import {
serializeLocalFilesDragPayload, serializeLocalFilesDragPayload,
sortLocalEntries, sortLocalEntries,
} from "../../src/frontend/local-transfer-utils"; } 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", () => { describe("drag payloads", () => {
it("round-trips local file payloads", () => { it("round-trips local file payloads", () => {
+2 -1
View File
@@ -70,6 +70,7 @@
"descriptionKey": "permissions.execute.description", "descriptionKey": "permissions.execute.description",
"defaultRoles": ["user"] "defaultRoles": ["user"]
} }
] ],
"uses": ["snippets.extractInputs"]
} }
} }
+23 -2
View File
@@ -6,10 +6,31 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": {
"clsx": "^2.1.1",
"jszip": "^3.10.2",
"lucide-react": "^1.33.0",
"tailwind-merge": "^3.5.0"
},
"devDependencies": { "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 { function fakeHosts(hosts: PluginHostSummary[]): PluginHosts {
return { return <PluginHosts>(<unknown>{
list: async () => hosts, list: async () => hosts,
get: async (hostId) => hosts.find((h) => h.id === hostId) ?? null, get: async (hostId) => hosts.find((h) => h.id === hostId) ?? null,
checkAccess: async (hostId) => { checkAccess: async (hostId) => {
@@ -37,7 +37,7 @@ function fakeHosts(hosts: PluginHostSummary[]): PluginHosts {
share: async (hostId) => ({ hostId, shared: true }), share: async (hostId) => ({ hostId, shared: true }),
listUsers: async () => [], listUsers: async () => [],
listRoles: async () => [], listRoles: async () => [],
}; });
} }
async function setup(hosts: PluginHostSummary[]) { 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; const service = server.mock.services.get("fleets.access") as FleetsService;
await expect( 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"); ).rejects.toThrow("fleets.manage");
}); });
it("adds only hosts the caller can see", async () => { it("adds only hosts the caller can see", async () => {
server = await startServer(); server = await startServer();
const service = server.mock.services.get("fleets.access") as FleetsService; const service = server.mock.services.get("fleets.access") as FleetsService;
const run = <T>(fn: () => Promise<T>) => const run = <T>(fn: () => Promise<T>) => server!.mock.actAs("user-1", fn);
server!.mock.ctx.asUser("user-1", fn);
const fleet = await run(() => service.create({ name: "prod" })); const fleet = await run(() => service.create({ name: "prod" }));
await run(() => service.addMember(fleet.id, 10)); await run(() => service.addMember(fleet.id, 10));
+19 -2
View File
@@ -6,10 +6,27 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": { "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); scheduleSave(next);
return next; return next;
}); });
} catch {} } catch {
// A bad layout write is dropped; the canvas keeps what it shows.
}
}, },
[scheduleSave], [scheduleSave],
); );
@@ -376,7 +378,9 @@ export function HomepageCanvas({
scheduleSave(next); scheduleSave(next);
return next; return next;
}); });
} catch {} } catch {
// A bad layout write is dropped; the canvas keeps what it shows.
}
}, },
[scheduleSave], [scheduleSave],
); );
@@ -403,7 +407,9 @@ export function HomepageCanvas({
setWidgets((prev) => setWidgets((prev) =>
prev.map((w) => (w.id === id ? { ...w, title, config } : w)), 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, component: HomepagePreviewCard,
}); });
app.declareActionSlot({
id: "dashboard.secondaryView",
accepts: ["component"],
});
app.registerSlotContribution("dashboard.secondaryView", { app.registerSlotContribution("dashboard.secondaryView", {
actionId: "homepage", actionId: "homepage",
titleKey: "nav.homepage", titleKey: "nav.homepage",
@@ -4,7 +4,7 @@ import {
useHostActions, useHostActions,
useHostStatus, useHostStatus,
useHosts, useHosts,
type PluginHostRecord, useTabs,
} from "@termix/plugin-sdk/frontend"; } from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry"; import { registerWidget } from "./WidgetRegistry";
@@ -18,14 +18,6 @@ import { GRID_SIZE } from "../types.js";
import { quickConnectTargets } from "../quick-connect-targets.js"; import { quickConnectTargets } from "../quick-connect-targets.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui"; 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 }) { function StatusDot({ hostId }: { hostId: number }) {
const status = useHostStatus(hostId); const status = useHostStatus(hostId);
const cls = const cls =
@@ -43,6 +35,7 @@ function QuickConnectWidget({
}: WidgetComponentProps<QuickConnectConfig>) { }: WidgetComponentProps<QuickConnectConfig>) {
const { t } = useTranslation(); const { t } = useTranslation();
const { hosts: allHosts, loaded } = useHosts(); const { hosts: allHosts, loaded } = useHosts();
const tabs = useTabs();
const targets = quickConnectTargets(useHostActions()); const targets = quickConnectTargets(useHostActions());
const hosts = const hosts =
@@ -104,7 +97,7 @@ function QuickConnectWidget({
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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" 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", "db:own",
"network:serve", "network:serve",
"ui:surface", "ui:surface",
"events:core" "events:core",
"users:impersonate"
], ],
"provides": [ "provides": [
{ {
@@ -153,7 +154,12 @@
"advanced": { "advanced": {
"columns": 4 "columns": 4
} }
} },
"uses": [
"homepage.hostMetrics",
"homepage.widgets",
"terminal.toolbarStatus"
]
}, },
"locales": "locales", "locales": "locales",
"platforms": ["linux", "win32", "darwin"] "platforms": ["linux", "win32", "darwin"]
+22 -2
View File
@@ -6,10 +6,30 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": { "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({ export function HostStatsTab({
form, form,
setField,
updateForm, updateForm,
host, host,
}: HostEditorSectionProps) { }: 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) { export function createDashboardHostMetrics(store: MetricsSummaryStore) {
return function DashboardHostMetrics(props: Record<string, unknown>) { return function DashboardHostMetrics(props: Record<string, unknown>) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -13,13 +13,6 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api"; import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs"; 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({ export function CpuCard({
metrics, metrics,
history, 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. // The usage bars in the dashboard's host status card.
app.registerSlotContribution("dashboard.hostMetrics", { app.registerSlotContribution("dashboard.hostRow", {
actionId: "host-metrics.dashboardHost", actionId: "host-metrics.dashboardHost",
titleKey: "nav.hostMetrics", titleKey: "nav.hostMetrics",
kind: "component", kind: "component",
@@ -32,8 +32,10 @@ describe("deriveEnabledWidgets", () => {
it("output is always a subset of the 10 known WidgetTypes", () => { it("output is always a subset of the 10 known WidgetTypes", () => {
const slots = METRIC_CARD_IDS.map((id) => ({ id })).concat([ const slots = METRIC_CARD_IDS.map((id) => ({ id })).concat([
{ id: "user_manager" } as { id: (typeof METRIC_CARD_IDS)[number] }, { id: "user_manager" } as unknown as {
{ id: "bogus" } as { id: (typeof METRIC_CARD_IDS)[number] }, id: (typeof METRIC_CARD_IDS)[number];
},
{ id: "bogus" } as unknown as { id: (typeof METRIC_CARD_IDS)[number] },
]); ]);
const out = deriveEnabledWidgets(slots); const out = deriveEnabledWidgets(slots);
expect(out).toEqual(METRIC_CARD_IDS); 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 () => { it("fills core's metric slots and offers disk usage to other plugins", async () => {
rendered = await renderWithApp(plugin, { manifest, locales }); rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.slot("dashboard.hostMetrics")).toEqual([ expect(rendered.registered.slot("dashboard.hostRow")).toEqual([
"host-metrics.dashboardHost", "host-metrics.dashboardHost",
]); ]);
expect(rendered.registered.slot("homepage.hostMetrics")).toEqual([ expect(rendered.registered.slot("homepage.hostMetrics")).toEqual([
+19 -4
View File
@@ -6,14 +6,29 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": { "dependencies": {
"ldapjs": "^3.0.7" "ldapjs": "^3.0.7",
"lucide-react": "^1.33.0"
}, },
"devDependencies": { "devDependencies": {
"@termix/plugin-sdk": "*", "@termix/plugin-sdk": "^1.0.0",
"@types/ldapjs": "^3.0.6" "@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": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": { "dependencies": {
"cytoscape": "^3.34.3", "cytoscape": "^3.34.3",
"lucide-react": "^1.33.0",
"react-cytoscapejs": "^2.0.0" "react-cytoscapejs": "^2.0.0"
}, },
"devDependencies": { "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, useTranslation,
usePluginApi, usePluginApi,
useHosts, useHosts,
useTabs,
getHostStatusColorScheme,
useHostActions, useHostActions,
type PluginHostRecord, type PluginHostRecord,
} from "@termix/plugin-sdk/frontend"; } from "@termix/plugin-sdk/frontend";
@@ -101,24 +103,6 @@ interface NetworkGraphCardProps {
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge; 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 { function resolveCssVar(varName: string, fallback: string): string {
const raw = getComputedStyle(document.documentElement) const raw = getComputedStyle(document.documentElement)
.getPropertyValue(varName) .getPropertyValue(varName)
@@ -151,7 +135,7 @@ function buildNodeSvg(
): string { ): string {
const isOnline = status === "online"; const isOnline = status === "online";
const isOffline = status === "offline"; const isOffline = status === "offline";
const useRealColors = readStatusColorScheme() === "status"; const useRealColors = getHostStatusColorScheme() === "status";
let statusColor: string; let statusColor: string;
if (isOnline) { if (isOnline) {
statusColor = useRealColors statusColor = useRealColors
@@ -583,10 +567,10 @@ export function NetworkGraphCard({
const hideMenu = () => setContextMenu((p) => ({ ...p, visible: false })); const hideMenu = () => setContextMenu((p) => ({ ...p, visible: false }));
const shellTabs = useTabs();
const fireOpen = (hostId: string, type?: string) => { const fireOpen = (hostId: string, type?: string) => {
window.dispatchEvent( const host = liveHosts.find((candidate) => String(candidate.id) === hostId);
new CustomEvent("termix:open-tab", { detail: { hostId, type } }), if (host) shellTabs.connectHost(host, type);
);
}; };
const handleContextAction = (action: string) => { const handleContextAction = (action: string) => {
@@ -38,7 +38,7 @@ describe("network-topology activate", () => {
const service = server.mock.services.get("network-topology.graph") as { const service = server.mock.services.get("network-topology.graph") as {
get: () => Promise<unknown>; 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: [] }); 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 { const service = server.mock.services.get("network-topology.graph") as {
get: () => Promise<unknown>; 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(); expect(result).toBeNull();
}); });
+2 -1
View File
@@ -52,7 +52,8 @@
"to": "/callback" "to": "/callback"
} }
] ]
} },
"uses": ["terminal.overlay"]
}, },
"locales": "locales", "locales": "locales",
"platforms": ["linux", "win32", "darwin"] "platforms": ["linux", "win32", "darwin"]
+20 -4
View File
@@ -6,14 +6,30 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": { "dependencies": {
"js-yaml": "^5.2.3" "js-yaml": "^5.2.3",
"lucide-react": "^1.33.0"
}, },
"devDependencies": { "devDependencies": {
"@termix/plugin-sdk": "*", "@termix/plugin-sdk": "^1.0.0",
"@types/js-yaml": "^4.0.9" "@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", "proxmoxLastSyncSummary": "{{created}} created, {{updated}} updated, {{markedMissing}} missing, {{skipped}} skipped",
"proxmoxLastSyncStatus": { "proxmoxLastSyncStatus": {
"error": "Failed", "error": "Failed",
"pending": "Pending" "pending": "Pending",
"success": "Success"
}, },
"proxmoxDiscoverAction": "Discover & import Proxmox guests", "proxmoxDiscoverAction": "Discover & import Proxmox guests",
"proxmoxImportTitle": "Import from Proxmox", "proxmoxImportTitle": "Import from Proxmox",
+2 -1
View File
@@ -21,7 +21,8 @@
"ssh:connect", "ssh:connect",
"db:own", "db:own",
"network:serve", "network:serve",
"ui:surface" "ui:surface",
"users:impersonate"
], ],
"contributes": { "contributes": {
"tabs": [ "tabs": [
+19 -2
View File
@@ -6,10 +6,27 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": { "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 { 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 { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@termix/plugin-sdk/ui"; import { Button } from "@termix/plugin-sdk/ui";
@@ -201,7 +204,7 @@ export function ProxmoxDiscoverDialog({
if (toImport.length) { if (toImport.length) {
const updated = await listHosts(); const updated = await listHosts();
onHostsChanged(updated); onHostsChanged(updated);
window.dispatchEvent(new CustomEvent("termix:hosts-changed")); notifyHostsChanged();
} }
const msg = [ const msg = [
@@ -1,12 +1,4 @@
const SECRET_BACKED_AUTH_TYPES = new Set(["password", "key"]); const SECRET_BACKED_AUTH_TYPES = new Set(["password", "key"]);
const SECRETLESS_AUTH_TYPES = new Set([
"none",
"agent",
"opkssh",
"stepca",
"tailscale",
"vault",
]);
export type ProxmoxImportAuth = { export type ProxmoxImportAuth = {
authType: string; authType: string;
@@ -18,8 +10,13 @@ export function resolveProxmoxImportAuth(
defaultAuthType: string | undefined, defaultAuthType: string | undefined,
credentialId: number | null | undefined, credentialId: number | null | undefined,
): ProxmoxImportAuth { ): ProxmoxImportAuth {
// An explicit secretless auth choice (none/opkssh/tailscale/vault) wins. // Any auth type that needs no stored secret (none, agent, or one a plugin
if (defaultAuthType && SECRETLESS_AUTH_TYPES.has(defaultAuthType)) { // adds) wins, the same rule the backend copy applies.
if (
defaultAuthType &&
defaultAuthType !== "credential" &&
!SECRET_BACKED_AUTH_TYPES.has(defaultAuthType)
) {
return { authType: 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" }; return { authType: "none" };
} }
@@ -86,7 +86,7 @@ describe("ProxmoxPollingManager", () => {
}; };
const manager = new ProxmoxPollingManager<TestHost>({ const manager = new ProxmoxPollingManager<TestHost>({
fetchHostById, fetchHostById,
withSshConnection, withSshConnection: withSshConnection as never,
historyRepository, historyRepository,
}); });
return { manager, fetchHostById, withSshConnection, historyRepository }; return { manager, fetchHostById, withSshConnection, historyRepository };
+7 -4
View File
@@ -7,7 +7,7 @@
"name": "Termix" "name": "Termix"
}, },
"license": "MIT", "license": "MIT",
"repository": "https://github.com/Termix-SSH/Plugin-RemoteDesktop", "repository": "https://github.com/Termix-SSH/Termix",
"category": "Terminal", "category": "Terminal",
"icon": "Monitor", "icon": "Monitor",
"engine": { "engine": {
@@ -21,7 +21,8 @@
"ssh:connect", "ssh:connect",
"network:serve", "network:serve",
"desktop:window", "desktop:window",
"ui:surface" "ui:surface",
"users:impersonate"
], ],
"optionalDependencies": { "optionalDependencies": {
"session-recording": "^1.0.0", "session-recording": "^1.0.0",
@@ -442,7 +443,8 @@
"labelKey": "settings.host.guacamoleConfig.label", "labelKey": "settings.host.guacamoleConfig.label",
"default": {}, "default": {},
"hidden": true, "hidden": true,
"shareRead": "edit" "shareRead": "edit",
"secretKeys": ["gateway-password"]
}, },
{ {
"key": "enableToolbar", "key": "enableToolbar",
@@ -454,7 +456,8 @@
} }
] ]
} }
} },
"uses": ["session.remoteDisplay", "wakeOnLan.macAddress"]
}, },
"locales": "locales", "locales": "locales",
"platforms": ["linux", "win32", "darwin"] "platforms": ["linux", "win32", "darwin"]
+27 -2
View File
@@ -6,10 +6,35 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": {
"guacamole-common-js": "^1.5.0",
"guacamole-lite": "^1.2.0",
"lucide-react": "^1.33.0"
},
"devDependencies": { "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 fs = require("fs");
const path = require("path"); 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( const guacdClientPath = path.join(
__dirname, packageDir("guacamole-lite"),
"..",
"node_modules",
"guacamole-lite",
"lib", "lib",
"GuacdClient.js", "GuacdClient.js",
); );
const cryptPath = path.join( const cryptPath = path.join(packageDir("guacamole-lite"), "lib", "Crypt.js");
__dirname,
"..",
"node_modules",
"guacamole-lite",
"lib",
"Crypt.js",
);
const clientConnectionPath = path.join( const clientConnectionPath = path.join(
__dirname, packageDir("guacamole-lite"),
"..",
"node_modules",
"guacamole-lite",
"lib", "lib",
"ClientConnection.js", "ClientConnection.js",
); );
const serverPath = path.join( const serverPath = path.join(packageDir("guacamole-lite"), "lib", "Server.js");
__dirname,
"..",
"node_modules",
"guacamole-lite",
"lib",
"Server.js",
);
if ( if (
!fs.existsSync(guacdClientPath) || !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" }); return res.status(400).json({ error: "Missing host" });
} }
try { try {
const result = await ctx.desktop.launchNativeRdp({ const result = await ctx.desktop.launchExternalClient({
protocol: "rdp",
host: body.host, host: body.host,
port: Number(body.port) || DEFAULT_PORT.rdp, port: Number(body.port) || DEFAULT_PORT.rdp,
username: typeof body.username === "string" ? body.username : undefined, username: typeof body.username === "string" ? body.username : undefined,
@@ -117,17 +117,8 @@ export class GuacamoleTokenService {
} }
} }
const jwtSecret = process.env.JWT_SECRET; // A token lives for seconds between issue and connect, so a fresh key
if (jwtSecret) { // per boot is enough and nothing is derived from core's own secrets.
return crypto
.createHash("sha256")
.update(jwtSecret + "_guacamole")
.digest();
}
this.log.warn("No persistent encryption key found, generating random key", {
operation: "guac_key_generation",
});
return crypto.randomBytes(KEY_LENGTH); return crypto.randomBytes(KEY_LENGTH);
} }
@@ -15,13 +15,13 @@ import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
import { import {
buildOriginWsUrl, buildOriginWsUrl,
getBasePath, getBasePath,
guacStateToStage,
isElectron, isElectron,
resolveConnectionOrigin, resolveConnectionOrigin,
type ConnectionOrigin, type ConnectionOrigin,
type ConnectionStage, type ConnectionStage,
} from "@termix/plugin-sdk/ui"; } from "@termix/plugin-sdk/ui";
import { errorMessage } from "./host-remote"; import { errorMessage } from "./host-remote";
import { guacStateToStage } from "./guacamole-state.ts";
import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts"; import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts";
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts"; import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
import { bindPointerInput } from "./guacamole-pointer.ts"; import { bindPointerInput } from "./guacamole-pointer.ts";
@@ -88,8 +88,6 @@ interface GuacamoleDisplayProps {
onZoomChange?: (zoom: number) => void; onZoomChange?: (zoom: number) => void;
} }
const isDev = import.meta.env.DEV;
export const GuacamoleDisplay = forwardRef< export const GuacamoleDisplay = forwardRef<
GuacamoleDisplayHandle, GuacamoleDisplayHandle,
GuacamoleDisplayProps 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([5, 30, 33]));
socket.write(Buffer.from([36, 2, 35])); socket.write(Buffer.from([36, 2, 35]));
socket.once("data", (selection) => { socket.once("data", (selection) => {
selectedSecurityType = selection[0]; selectedSecurityType = Number(selection[0]);
socket.write("desktop-data"); socket.write("desktop-data");
}); });
}); });
@@ -17,8 +17,8 @@ import { GuacamoleTokenService } from "../../src/backend/token-service.js";
const manifest = manifestJson as unknown as PluginManifest; const manifest = manifestJson as unknown as PluginManifest;
// The token key comes from JWT_SECRET; without one each service picks its own. // Without a fixed key each token service picks its own.
process.env.JWT_SECRET = "remote-desktop-test-secret"; process.env.GUACAMOLE_ENCRYPTION_KEY = "ab".repeat(32);
function target( function target(
overrides: Partial<PluginProtocolTarget["auth"]> = {}, overrides: Partial<PluginProtocolTarget["auth"]> = {},
@@ -10,7 +10,7 @@ import {
isServerOwnedSetting, isServerOwnedSetting,
} from "../../src/backend/token-service.js"; } 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) { function decode(token: string) {
return JSON.parse(Buffer.from(token, "base64").toString("utf8")) as { return JSON.parse(Buffer.from(token, "base64").toString("utf8")) as {
@@ -59,8 +59,10 @@ describe("GuacamoleTokenService", () => {
const termixMeta = { const termixMeta = {
termixConnectId: "connect-1", termixConnectId: "connect-1",
hostId: 7, hostId: 7,
hostName: "windows",
ownerUserId: "user-1", ownerUserId: "user-1",
protocol: "rdp" as const, protocol: "rdp" as const,
tabInstanceId: null,
}; };
const token = tokenService.createRdpToken( const token = tokenService.createRdpToken(
"windows.example.test", "windows.example.test",
+2 -1
View File
@@ -20,7 +20,8 @@
"network:outbound", "network:outbound",
"network:serve", "network:serve",
"auth:provide", "auth:provide",
"ui:surface" "ui:surface",
"users:impersonate"
], ],
"contributes": { "contributes": {
"auth": { "auth": {
+20 -2
View File
@@ -6,10 +6,28 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": { "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", "titleKey": "nav.serial",
"icon": "Usb" "icon": "Usb"
} }
] ],
"uses": ["terminal.resolveTheme"]
}, },
"desktop": "local" "desktop": "local"
} }
+28 -2
View File
@@ -6,13 +6,39 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "typecheck": "tsc -p .",
"validate": "termix-plugin validate" "validate": "termix-plugin validate"
}, },
"dependencies": { "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" "serialport": "^13.0.0"
}, },
"devDependencies": { "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 type { Ref } from "react";
import { Usb } from "lucide-react"; import { Usb } from "lucide-react";
import type { import type {
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff. Show more