mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
+18
-41
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+662
-423
File diff suppressed because it is too large.
Load diff
+4
-35
@@ -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",
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+14
-6
@@ -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"),
|
||||||
+14
-8
@@ -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
|
||||||
Vendored
+13
@@ -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";
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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-]+)+$"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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}`,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
"descriptionKey": "permissions.execute.description",
|
"descriptionKey": "permissions.execute.description",
|
||||||
"defaultRoles": ["user"]
|
"defaultRoles": ["user"]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"uses": ["snippets.extractInputs"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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[]) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,8 @@
|
|||||||
"to": "/callback"
|
"to": "/callback"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"uses": ["terminal.overlay"]
|
||||||
},
|
},
|
||||||
"locales": "locales",
|
"locales": "locales",
|
||||||
"platforms": ["linux", "win32", "darwin"]
|
"platforms": ["linux", "win32", "darwin"]
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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,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"]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+17
-24
@@ -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) ||
|
||||||
@@ -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
|
||||||
|
|||||||
Vendored
File renamed without changes.
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,8 @@
|
|||||||
"titleKey": "nav.serial",
|
"titleKey": "nav.serial",
|
||||||
"icon": "Usb"
|
"icon": "Usb"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"uses": ["terminal.resolveTheme"]
|
||||||
},
|
},
|
||||||
"desktop": "local"
|
"desktop": "local"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
Reference in new issue
Block a user