chore: lock the plugin boundary and remove 2.9.0 leftovers

This commit is contained in:
LukeGus committed 2026-09-25 07:24:46 -05:00
1 parent 6489b62ece
commit 2961297989
399 files changed
+3964 -5627

No files matched your search

+24 -49
View File
@@ -174,7 +174,7 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-q2DmZRcdiLWe4+k149nTr+u9AGPLVk+Bx0aWPFnz5bU='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-gQLhWUEDVfLtBiv83VAckuSAVmhxOpOHbNpFbODgPN0='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
@@ -271,24 +271,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/snippets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location = /vault/oidc/callback {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/sync(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -298,15 +280,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ^~ /termix-id/u/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/audit-logs(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -336,8 +309,29 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
# Automation webhooks lived at /automations/webhook/<token> before
# 2.9.0 and external systems still call that URL.
# 2.8 URLs that something outside Termix still calls: the Vault OIDC
# callback, the Termix ID resolver and automation webhooks. The
# backend redirects each to the plugin that serves it now (see
# contributes.http in each plugin's manifest). New plugins never add
# a block here.
location = /vault/oidc/callback {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ^~ /termix-id/u/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /automations/webhook/ {
rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last;
}
@@ -455,25 +449,6 @@ http {
proxy_buffering off;
}
location ~ ^/db(/.*)?$ {
client_max_body_size 5G;
client_body_timeout 300s;
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_request_buffering off;
proxy_buffering off;
}
location ~ ^/encryption(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
+24 -49
View File
@@ -144,7 +144,7 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-q2DmZRcdiLWe4+k149nTr+u9AGPLVk+Bx0aWPFnz5bU='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-gQLhWUEDVfLtBiv83VAckuSAVmhxOpOHbNpFbODgPN0='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
@@ -240,24 +240,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/snippets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location = /vault/oidc/callback {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/sync(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -267,15 +249,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ^~ /termix-id/u/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/audit-logs(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -305,8 +278,29 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
# Automation webhooks lived at /automations/webhook/<token> before
# 2.9.0 and external systems still call that URL.
# 2.8 URLs that something outside Termix still calls: the Vault OIDC
# callback, the Termix ID resolver and automation webhooks. The
# backend redirects each to the plugin that serves it now (see
# contributes.http in each plugin's manifest). New plugins never add
# a block here.
location = /vault/oidc/callback {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ^~ /termix-id/u/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /automations/webhook/ {
rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last;
}
@@ -424,25 +418,6 @@ http {
proxy_buffering off;
}
location ~ ^/db(/.*)?$ {
client_max_body_size 5G;
client_body_timeout 300s;
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_request_buffering off;
proxy_buffering off;
}
location ~ ^/encryption(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
+4 -3
View File
@@ -1,3 +1,4 @@
DROP TABLE `dashboard_service_links`;--> statement-breakpoint
DROP TABLE `homepage_items`;--> statement-breakpoint
DROP TABLE `homepage_layouts`;
-- dashboard_service_links, homepage_items and homepage_layouts moved to the
-- homepage plugin, which adopts them by rename at activation. Dropping them
-- here would delete every row before the plugin's migration runs.
SELECT 1;
+7 -5
View File
@@ -317,16 +317,18 @@ function writeToRequest(request, chunk) {
}
const TRANSFER_ROUTES = Object.freeze({
uploadFileStream: "/ssh/uploadFileStream",
downloadFileStream: "/ssh/downloadFileStream",
uploadFileStream: "/uploadFileStream",
downloadFileStream: "/downloadFileStream",
});
const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
// A compact JWT (base64url segments joined by dots); anything else is refused.
const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
const DEFAULT_LOCAL_FILE_MANAGER_BASE =
"http://localhost:30004/ssh/file_manager";
// The file-manager plugin serves both routes under /plugin-api/file-manager
// on the main backend.
const FILE_MANAGER_API = "/plugin-api/file-manager";
const DEFAULT_LOCAL_FILE_MANAGER_BASE = `http://localhost:30001${FILE_MANAGER_API}`;
function normalizeHttpBase(candidate, what) {
let parsed;
@@ -408,7 +410,7 @@ function createTargetResolver({
const jwt =
typeof getRemoteSyncJwt === "function" ? getRemoteSyncJwt() : null;
if (jwt) headers.Authorization = `Bearer ${jwt}`;
return { url: `${base}/ssh/file_manager${routePath}`, headers };
return { url: `${base}${FILE_MANAGER_API}${routePath}`, headers };
}
throw new Error(`Unknown transfer origin: ${String(origin)}`);
+1 -1
View File
@@ -1050,7 +1050,7 @@ function getBackendPidFilePath() {
// Manager) rather than through the normal quit flow, will-quit never fires
// and stopBackendServer() never runs -- the forked backend child is a
// genuinely separate OS process on Windows/mac/Linux, so it keeps running
// and holding every port the backend binds (30001, 30004). Every subsequent
// and holding the port the backend binds (30001). Every subsequent
// launch's own backend then fails outright
// with EADDRINUSE and the app is stuck until something manually kills the
// orphan. Reap any such leftover process, identified by PID file, before
+51 -57
View File
@@ -5,6 +5,49 @@ import reactRefresh from "eslint-plugin-react-refresh";
import unusedImports from "eslint-plugin-unused-imports";
import tseslint from "typescript-eslint";
import { globalIgnores } from "eslint/config";
import path from "node:path";
// A plugin may import @termix/plugin-sdk, npm packages and its own files.
// A relative path that climbs out of plugins/<id>/ reaches core or another
// plugin, and "@/..." is core's alias.
const pluginBoundary = {
rules: {
"stay-inside": {
meta: {
type: "problem",
messages: {
core: "A plugin reaches core only through @termix/plugin-sdk. Use an SDK API, or add one.",
outside:
"A plugin cannot import files outside its own folder. Use the SDK, ctx.services, events, actions or slots.",
},
},
create(context) {
const file = context.filename.replace(/\\/g, "/");
const root = /^(.*\/plugins\/[^/]+)\//.exec(file)?.[1];
const check = (node, source) => {
if (typeof source !== "string") return;
if (source === "@" || source.startsWith("@/")) {
context.report({ node, messageId: "core" });
return;
}
if (!root || !source.startsWith(".")) return;
const target = path.posix.normalize(
path.posix.join(path.posix.dirname(file), source),
);
if (target !== root && !target.startsWith(`${root}/`)) {
context.report({ node, messageId: "outside" });
}
};
return {
ImportDeclaration: (node) => check(node, node.source.value),
ExportNamedDeclaration: (node) => check(node, node.source?.value),
ExportAllDeclaration: (node) => check(node, node.source.value),
ImportExpression: (node) => check(node, node.source.value),
};
},
},
},
};
export default tseslint.config([
globalIgnores([
@@ -108,17 +151,10 @@ export default tseslint.config([
// Core must not reach into a plugin. A plugin can be disabled, upgraded
// or removed, so an import from core turns "disabled" into a broken
// build rather than a missing feature. Core talks to plugins through the
// runtime in src/backend/plugins/ and the dispatchers in
// database/routes/*-dispatch.ts, both of which handle absence.
//
// Tests are exempt: they import a plugin's modules on purpose to test
// them, and until A2 gives each plugin its own suite they live under
// src/*/tests/plugins/.
// runtime in src/backend/plugins/, which handles absence. Core tests only
// use fixture plugins.
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/**/tests/**"],
rules: {
// An error for the backend, which has dispatchers to register through
// and no remaining offenders.
"no-restricted-imports": [
"error",
{
@@ -126,7 +162,7 @@ export default tseslint.config([
{
group: ["**/plugins/*/src/backend/**"],
message:
"Core must not import a plugin backend. Register a dispatcher or an SDK service instead, so disabling the plugin degrades cleanly.",
"Core must not import a plugin backend. Go through the plugin runtime, so disabling the plugin degrades cleanly.",
},
],
},
@@ -139,7 +175,6 @@ export default tseslint.config([
// also carries the backend rule for src/ui files, because flat config
// replaces a rule's options rather than merging them.
files: ["src/ui/**/*.{ts,tsx}", "src/main.tsx"],
ignores: ["src/ui/tests/**"],
rules: {
"no-restricted-imports": [
"error",
@@ -156,53 +191,12 @@ export default tseslint.config([
},
},
{
// The other direction. A plugin is supposed to talk to core only through
// @termix/plugin-sdk, which is the contract D1 finishes enforcing.
//
// Only the backend is fenced today. Plugin backends reach core by
// relative path (../../../src/backend/...) and plugin frontends use the
// "@/" alias for shared UI, both of which are the "legacy core imports"
// debt recorded in packages/plugin-sdk/ARCHITECTURE.md: the SDK does not
// yet expose the SSH pool, host resolution, repositories or the
// component library. A7 moves the frontends onto the app object and D1
// moves the backends onto SDK APIs, and this rule tightens to match.
//
// What it already catches: a plugin backend importing the frontend tree,
// which is not debt in any direction, just a mistake.
files: ["plugins/*/src/backend/**/*.{ts,mjs}"],
// The other direction. A plugin reaches core only through
// @termix/plugin-sdk, and never reaches into another plugin's source.
files: ["plugins/*/src/**/*.{ts,tsx,mjs}", "plugins/*/tests/**/*.{ts,tsx}"],
plugins: { "termix-plugins": pluginBoundary },
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@/*", "**/src/ui/**"],
message:
"A plugin backend cannot import frontend code. Import from @termix/plugin-sdk, or use a relative path within the plugin.",
},
],
},
],
// A second rule name rather than a second pattern, because flat config
// replaces a rule's options rather than merging them: one rule cannot
// carry both severities, and folding this into the error above would
// fail the build on debt that has nowhere to go yet.
//
// scripts/plugin-boundary-allowlist.json holds today's offenders and
// scripts/check-plugin-boundaries.cjs fails on a new one. D1 empties
// the list and this becomes an error.
"@typescript-eslint/no-restricted-imports": [
"warn",
{
patterns: [
{
group: ["**/src/backend/**", "**/src/types/**"],
message:
"A plugin should reach core through @termix/plugin-sdk. This relative import is legacy debt tracked in scripts/plugin-boundary-allowlist.json.",
},
],
},
],
"termix-plugins/stay-inside": "error",
},
},
]);
+120 -90
View File
@@ -1,93 +1,123 @@
{
"$schema": "https://unpkg.com/knip@latest/schema.json",
"entry": [
"src/backend/starter.ts",
"src/backend/swagger.ts",
"scripts/**/*.cjs"
],
"project": ["src/**/*.{ts,tsx}", "electron/**/*.cjs", "scripts/**/*.cjs"],
"ignoreBinaries": ["powershell"],
"ignoreDependencies": [
"@codemirror/autocomplete",
"@codemirror/commands",
"@codemirror/search",
"@codemirror/theme-one-dark",
"@codemirror/view",
"@electron/notarize",
"@monaco-editor/react",
"@radix-ui/react-accordion",
"@radix-ui/react-alert-dialog",
"@radix-ui/react-checkbox",
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-label",
"@radix-ui/react-popover",
"@radix-ui/react-progress",
"@radix-ui/react-scroll-area",
"@radix-ui/react-select",
"@radix-ui/react-separator",
"@radix-ui/react-slider",
"@radix-ui/react-slot",
"@radix-ui/react-switch",
"@radix-ui/react-tabs",
"@radix-ui/react-tooltip",
"@types/better-sqlite3",
"@types/cookie-parser",
"@types/cors",
"@types/express",
"@types/guacamole-common-js",
"@types/js-yaml",
"@types/jsonwebtoken",
"@types/multer",
"@types/ssh2",
"@uiw/codemirror-extensions-langs",
"@uiw/codemirror-theme-github",
"@uiw/react-codemirror",
"@xterm/addon-clipboard",
"@xterm/addon-fit",
"@xterm/addon-unicode11",
"@xterm/addon-web-links",
"@xterm/xterm",
"axios",
"bcryptjs",
"better-sqlite3",
"body-parser",
"chalk",
"class-variance-authority",
"clsx",
"cmdk",
"cookie-parser",
"cors",
"cytoscape",
"drizzle-orm",
"express",
"guacamole-lite",
"guacamole-common-js",
"js-yaml",
"jsonwebtoken",
"jszip",
"multer",
"nanoid",
"i18next-browser-languagedetector",
"lucide-react",
"motion",
"radix-ui",
"react-cytoscapejs",
"react-h5-audio-player",
"react-hook-form",
"react-icons",
"react-markdown",
"react-pdf",
"react-photo-view",
"react-syntax-highlighter",
"react-xtermjs",
"remark-gfm",
"socks",
"ssh2",
"undici",
"husky",
"sharp",
"sonner",
"tailwind-merge"
]
"workspaces": {
".": {
"entry": [
"src/main.tsx",
"src/backend/starter.ts",
"src/backend/utils/swagger.ts",
"src/ui/plugin-host/sdk-ui.ts",
"src/ui/plugin-host/testing-host.tsx",
"src/**/tests/**/*.{ts,tsx}",
"scripts/**/*.{cjs,mjs}",
"electron/main.cjs",
"electron/preload.js",
"vitest.config.ts",
"vitest.setup.ts",
"vite.config.ts"
],
"project": [
"src/**/*.{ts,tsx}",
"electron/**/*.cjs",
"scripts/**/*.{cjs,mjs}"
],
"ignoreBinaries": ["powershell"],
"ignoreDependencies": [
"@codemirror/autocomplete",
"@codemirror/commands",
"@codemirror/search",
"@codemirror/theme-one-dark",
"@codemirror/view",
"@electron/notarize",
"@monaco-editor/react",
"@radix-ui/react-accordion",
"@radix-ui/react-alert-dialog",
"@radix-ui/react-checkbox",
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-label",
"@radix-ui/react-popover",
"@radix-ui/react-progress",
"@radix-ui/react-scroll-area",
"@radix-ui/react-select",
"@radix-ui/react-separator",
"@radix-ui/react-slider",
"@radix-ui/react-slot",
"@radix-ui/react-switch",
"@radix-ui/react-tabs",
"@radix-ui/react-tooltip",
"@types/better-sqlite3",
"@types/cookie-parser",
"@types/cors",
"@types/express",
"@types/guacamole-common-js",
"@types/js-yaml",
"@types/jsonwebtoken",
"@types/multer",
"@types/ssh2",
"@uiw/codemirror-extensions-langs",
"@uiw/codemirror-theme-github",
"@uiw/react-codemirror",
"@xterm/addon-clipboard",
"@xterm/addon-fit",
"@xterm/addon-unicode11",
"@xterm/addon-web-links",
"@xterm/xterm",
"axios",
"bcryptjs",
"better-sqlite3",
"body-parser",
"chalk",
"class-variance-authority",
"clsx",
"cmdk",
"cookie-parser",
"cors",
"cytoscape",
"drizzle-orm",
"express",
"guacamole-lite",
"guacamole-common-js",
"js-yaml",
"jsonwebtoken",
"jszip",
"multer",
"nanoid",
"i18next-browser-languagedetector",
"lucide-react",
"motion",
"radix-ui",
"react-cytoscapejs",
"react-h5-audio-player",
"react-hook-form",
"react-icons",
"react-markdown",
"react-pdf",
"react-photo-view",
"react-syntax-highlighter",
"react-xtermjs",
"remark-gfm",
"socks",
"ssh2",
"undici",
"husky",
"sharp",
"sonner",
"tailwind-merge"
]
},
"packages/plugin-sdk": {
"entry": ["src/*.ts", "src/testing/*.ts", "cli/**/*.mjs"],
"project": ["src/**/*.ts", "cli/**/*.mjs"]
},
"plugins/*": {
"entry": [
"src/backend/index.ts",
"src/frontend/index.{ts,tsx}",
"tests/**/*.{ts,tsx}",
"vitest.config.ts"
],
"project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"]
}
}
}
+3 -3
View File
@@ -19,7 +19,7 @@
"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",
"prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && npm run plugin:validate-manifest && eslint . && node scripts/check-plugin-boundaries.cjs && node scripts/check-importmap-csp.cjs && node scripts/check-shell-plugin-ids.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",
"lint:fix": "eslint --fix .",
"type-check": "tsc -b --force",
"test": "vitest run",
@@ -31,8 +31,8 @@
"build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"dev:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins && node ./dist/backend/backend/starter.js",
"dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev",
"dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev",
"dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001:30001 -v \"%cd%\\db\\data:/app/data\" termix:dev",
"dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001:30001 -v \"%cd%\\db\\data:/app/data\" termix:dev",
"generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/utils/swagger.js",
"preview": "vite preview",
"electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"",
+194 -177
View File
@@ -43,8 +43,16 @@ dependencies. Entry points:
Plugins never import from `src/` or `@/`, and core never imports from
`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK
API. Enforced by lint, with the exceptions recorded under
[legacy core imports](#legacy-core-imports-the-debt-d1-removes).
API. Enforced by lint, with no exceptions:
- `eslint.config.mjs` runs a local rule, `termix-plugins/stay-inside`, on
every file under `plugins/*/src` and `plugins/*/tests`. It refuses `@/...`
and any relative import that resolves outside `plugins/<id>/`, which covers
core (`src/`) and every other plugin.
- The same config refuses a `src/` file importing `plugins/*/src` or
`plugins/*/dist`.
- `scripts/check-core-plugin-ids.cjs` (see [UI](#8-ui)) refuses core naming a
plugin at all.
The SDK holds the contract: types, the catalog, and pure helpers. The ctx
implementation lives in core (`src/backend/plugins/ctx.ts`) and is injected at
@@ -78,7 +86,7 @@ can never be confused with colon-separated capabilities.
`contributes.permissions`, and core registers each as `<pluginId>.<name>`. The
plugin never writes the prefix, so it cannot claim a core group or another
plugin's namespace: `parseManifest` refuses a name starting with `hosts`,
`snippets`, `credentials`, `admin` or the plugin's own id, and the runtime
`credentials`, `admin` or the plugin's own id, and the runtime
re-checks against every namespace already in the catalog, because the validator
cannot know which other plugins exist.
@@ -184,6 +192,34 @@ Automations replaced them and A3 drops them. A core table needs all three
artifacts in lockstep: the `schema.ts` declaration, the `index.ts` DDL, and the
regenerated `schema.pg.ts`/`schema.mysql.ts` plus drizzle migrations.
**What stays in core.** After 2.9.0, `schema.ts`, the SQLite DDL in
`db/index.ts`, `performance-indexes.ts`, the repository factory and the
drizzle migrations hold only these 38 tables. Anything else is a plugin's,
under `p_<id>_`.
| Area | Tables |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Accounts | `users`, `sessions`, `trusted_devices`, `api_keys`, `user_external_identities`, `user_second_factors` |
| Preferences | `settings`, `user_preferences`, `ui_preferences`, `host_sidebar_preferences`, `credential_sidebar_preferences`, `user_open_tabs` |
| Hosts | `ssh_data`, `ssh_folders`, `recent_activity`, `dismissed_alerts` |
| Credentials | `ssh_credentials`, `ssh_credential_usage` |
| Sharing and RBAC | `roles`, `user_roles`, `host_access`, `folder_access`, `credential_access`, `shared_host_secrets`, `shared_host_auth_overrides`, `shared_credential_secrets`, `rbac_known_permissions`, `rbac_applied_defaults` |
| Audit and alerts | `audit_logs`, `notification_channels` |
| Sync | `sync_tombstones` |
| Plugin runtime | `plugins`, `plugin_permission_grants`, `plugin_registries`, `plugin_install_counts`, `plugin_storage`, `plugin_settings`, `plugin_migrations` |
A few core tables still carry columns a plugin reads through its own host or
user settings after the upgrade (the moved `ssh_data` feature columns, the
2.8 OIDC columns on `users`, `sessions.oidc_sub`/`oidc_sid`). They are
dropped in 3.0.0; see [Moving a host column into a plugin](#moving-a-host-column-into-a-plugin).
**Exports and imports follow the plugins.** The per-user SQLite export and
the JSON user export include every row the user owns in a plugin table: any
table a running plugin declared with a `refUser()` column
(`src/backend/plugins/user-data.ts`). Encrypted columns stay behind, since
they are sealed with this server's keys. An import reads each table by its
`p_` name, or by the legacy name it adopted for a file written before 2.9.
**Sync entities are registered, not hardcoded.** `ctx.sync.registerEntity`
adds an entity to remote sync; core registers its own the same way in
`database/routes/sync-entities.ts`. The wire names are unchanged, because
@@ -272,6 +308,26 @@ handler (noted for D2). Every route connection now also carries `clientIp`,
`requestOrigin` and `isDataUnlocked()`, the data-key check a long-lived socket
repeats per message so an expired session stops being served.
**Old URLs outside the namespace.** A few 2.8 URLs are called by something
outside Termix: identity provider callbacks, the Vault OIDC callback, the
Termix ID resolver, back-channel logout. A plugin lists them in
`contributes.http.legacyRedirects` as `{ from, to, status? }`. Core redirects
a request at `from` (or under it) to `/plugin-api/<id><to>` with the rest of
the path and the query kept, 307 by default or 308 for a permanent move.
`from` may be any absolute path outside the plugin framework's own prefixes,
and core routes win a clash. It is a redirect rather than a forward because a
cookie the plugin set is scoped to its own path. `contributes.http.legacyPaths`
is the other form, for URLs already under `/<plugin id>/` (automation
webhooks), which the plugin's router serves in place. 2.8 clients' login
routes (`/users/<method>/authorize`, `/users/<method>/login`) stay in core as
generic aliases of the login pipeline.
The nginx configs keep one small group of blocks for those public 2.8 URLs
(`/vault/oidc/callback`, `/termix-id/u/`, `/automations/webhook/`); the rest
already fall under core's `/users` and `/host` blocks. A new plugin never
needs an nginx change: everything it serves is under `/plugin-api/`,
`/plugin-ws/` or `/plugin-assets/`.
Socket and ssh2 event listeners fire outside the upgrade's async context, so a
plugin that calls privileged ctx members from them binds them with Node's
`AsyncResource.bind` while the actor is still set; the terminal does this for
@@ -371,14 +427,26 @@ allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
A plugin frontend is a built ESM bundle, `dist/frontend.js`, exporting
`activate(app)` and optionally `deactivate()`. The shell has no plugin ids and
no imports from `plugins/`; everything a plugin shows goes through the `app`
object. `scripts/check-shell-plugin-ids.cjs` (run by lint) fails on a quoted
plugin id in `src/ui` outside tests, apart from the entries in
`scripts/shell-plugin-id-allowlist.json`, each with a reason (host data like
the `docker` runtime name, which Phase B moves). **B14** removed the `rdp`,
`vnc` and `telnet` entries: the shell learns those protocols from
`app.registerHostProtocol`. **B15** removed both `docker` entries: the Hosts
panel's Docker filter and the bulk Docker toggles went with the column, and
the host editor no longer carries a Proxmox default that spelled `docker`.
object. `scripts/check-core-plugin-ids.cjs` (run by lint) scans all of `src/`
and fails on any plugin id core spells: a bare literal (`"docker"`), a plugin
route (`"/plugin-api/docker/..."`), or an action, slot or permission id that
starts with one (`"docker.open"`). The shell (`src/ui`) also may not spell a
view a plugin owns (a tab, panel or dashboard card id). There is no
allowlist. Three things are exempt, by rule rather than by entry:
- tests and locales;
- `src/backend/upgrade/`, the one-time 2.8 to 2.9 data moves, which have to
name the plugin each piece of data moves to, the same way
`LEGACY_TABLE_OWNERS` names the plugin that adopts each legacy table;
- `"totp"` inside `src/backend/hosts/connect/`, where it is the SSH
keyboard-interactive one-time-code prompt kind, not the login plugin.
Where core needs something from whichever plugin provides it, core names the
contract and a plugin fills it: a slot (`dashboard.counters`,
`admin.userTabs`), an action (`host.openFiles`, `snippet.list`,
`snippet.resolveForTerminal`) or a component id (`credentials.secretHint`,
`credentials.secretManager`). With no provider, the slot renders nothing and
the action answers `undefined`.
#### The loader
@@ -425,8 +493,8 @@ the full pass does not load the same bundle twice. See Auth below.
#### Shared modules and the import map
A bundle keeps `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`,
`@termix/plugin-sdk/ui` and `@termix/legacy-core/*` as bare imports. Core's
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend` and
`@termix/plugin-sdk/ui` as bare imports. Core's
Vite build (`scripts/vite-plugin-termix-plugins.mjs`) emits one entry per
shared module, writes a stable unhashed shim for each at
`dist/shared/<name>.js`, and injects an import map into `index.html` mapping
@@ -434,8 +502,7 @@ the bare names to those shims. In dev the map points at Vite's virtual
modules instead. The map is inline, so both nginx configs allow it by hash in
`script-src`; `scripts/check-importmap-csp.cjs` (run by lint) fails when the
hash is out of date and `--write` updates it. The map changes only when the
list of shared modules does, which includes the legacy-core modules plugins
import.
list of shared modules does.
Electron loads `index.html` from `file://`. The map uses relative URLs, and
plugin bundles come from the embedded backend
@@ -482,6 +549,22 @@ boundary and Suspense.
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
| `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which remote server it syncs with (**C6**) |
| `registerComponent` | A component other plugins and core render by id with `PluginComponent` |
| `onSettingsChanged` | Called when one of the plugin's settings changes |
A few contribution fields core reads generically:
| Field | Effect |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `RailItemContribution.simplePreset` | The item stays visible in the Simple interface preset, which hides every other plugin rail item |
| `HostActionContribution.quickConnect` | The action is offered as a button in Quick Connect, opening its `tabType` for an address that is never saved |
| `PluginSshAuthProvider.quickConnect` | (backend) The auth type works for an unsaved host, so Quick Connect lists it and renders the plugin's `SshAuthEditor` for its fields |
| `SshAuthTypeInfo.quickConnect` | What `useSshAuthTypes()` reports for the above |
Core calls outside a component (`@termix/plugin-sdk/frontend`): `logActivity`,
`getHostPassword`, `patchOpenTab`, `getCustomKeybindings`, `setHostAutoTmux`,
`getClientPreference`, `listHosts` (every host the user can see, fetched now)
and `listCredentials` (the user's credentials without secrets, for a picker).
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
name resolves to `<id>.<name>`), `useSettings`, `useHost`, `useHosts`,
@@ -494,7 +577,21 @@ to list manager cards plugins added to `host-metrics.managers` next to its
own). The SDK has no runtime dependencies: the hooks delegate to a host
bridge core installs.
Slots core owns: `terminal.toolbar`, `terminal.toolbarStatus`,
Slots and actions core names for plugins to fill: `dashboard.counters`
(component slot in the dashboard's counters card; tunnels shows its active
tunnel count there), `admin.userTabs` (component slot, one tab per
contribution in the admin "manage user" panel, rendered with `{ user }` and
replaced by core's locked notice while the user's data is locked; snippets
adds its tab), the `host.openFiles` action (the tab bar's file manager
button), the `snippet.list` and `snippet.resolveForTerminal` actions (core's
snippet pickers and the command palette's run flow) and the
`credentials.secretHint` / `credentials.secretManager` components (the
credential and host editors' secret reference UI). **D1** added these in
place of `tunnels.statuses`, `tunnels.open`, `files.openHost`,
`snippets.resolveForTerminal` and the `secret-sources.*` component ids, which
core used to call by the plugin's name.
Other slots: `terminal.toolbar`, `terminal.toolbarStatus`,
`terminal.sidePanel`, `terminal.overlay` (declared by ssh-terminal; **B9**
renamed `terminal.dock` to `terminal.sidePanel` and added the status slot,
where host-metrics puts its CPU, memory and disk bars; **C3** gave overlay
@@ -526,10 +623,7 @@ invoked with `{ hostId, sessionId, protocol, tabInstanceId }`.
**B3**) is host-metrics's own: tailscale contributes its manager card there
instead of host-metrics knowing tailscale exists, and host-metrics works with
or without tailscale enabled. Cross-plugin frontend calls go through actions:
`automations.list`, `fleets.list`, `session.remoteDisplay.token`. Core calls
plugin actions the same way when a core view shows a plugin's data:
`files.openHost`, and **B7**'s `tunnels.statuses` and `tunnels.open` behind the
dashboard's active tunnel counter, which reads as zero while tunnels is off.
`automations.list`, `fleets.list`, `session.remoteDisplay.token`.
**B16** added `host-metrics.disk` (the file manager's disk usage bar) and
`snippets.get` / `snippets.execute` (host-metrics's quick actions); each
answers undefined while its plugin is off.
@@ -628,6 +722,16 @@ calls a plugin makes for the signed-in user onto the typed host bridge, as
`@termix/plugin-sdk/frontend` functions: `logActivity`, `getHostPassword`,
`patchOpenTab`, `getCustomKeybindings`, `setHostAutoTmux` and
`getClientPreference`.
**D1** added what the last plugins reaching into core used: `Kbd`,
`KbdKey` and `KbdSeparator`; `ConnectionLogPanel` and the `LogEntry`
type; `createFrontendLogger(name)`, a console logger in core's format; the
adaptive resource budget (`getAdaptiveResourceBudget`,
`markAdaptiveResourceUsed`, `runAdaptiveBackgroundTask`) so a plugin's
background work shares one budget with the shell; `getDeviceId`;
`createQuickConnectHost` and `isQuickConnectHost`; and the desktop app's
local file bridge types (`LocalFileEntry`, `LocalDirectoryListing`,
`LocalWalkResult`, `LocalTransferProgress` and the rest), for a plugin that
drives `window.electronAPI.localFs` itself.
**B16** added `LineChart`, `useAdaptivePolling`, `useAreaPreferences` (the
Appearance density and chart options), and the homepage widget pieces
`WidgetTitle` and `runVisibleInterval` (moved to `src/ui/lib/` in **B19**,
@@ -685,8 +789,13 @@ where Crowdin writes them. Only `en.json` is edited by hand.
#### Type checking
`tsconfig.plugins.frontend.json` checks every plugin frontend and its tests
with bundler resolution, and is part of `npm run type-check`.
`npm run type-check` covers every plugin through two root configs:
`tsconfig.plugins.frontend.json` (plugin frontends and their tests, bundler
resolution; it extends the app config because `@termix/plugin-sdk/ui` is
core's own `sdk-ui.ts`) and `tsconfig.plugins.backend.json` (plugin backends,
`nodenext`, the SDK's built `.d.ts`). Neither gives a plugin a path into
core; the `@/` alias the frontend config inherits exists for `sdk-ui.ts`
itself, and lint refuses it in plugin code.
### 9. Cross-plugin use
@@ -1296,8 +1405,8 @@ Every SSH connection, core's and plugins', goes through
decryption, shared-host overrides and shared agent, and `op://` external
secret references.
2. `buildConnectConfig` builds the ssh2 config: the defaults for the
connection's purpose (terminal, file-manager, tmux, metrics, fleet, docker,
...), the host key verifier, then the auth provider's `prepare`.
connection's profile (terminal, session, stream, forward, background or
jump), the host key verifier, then the auth provider's `prepare`.
3. `openSshTransport` does port knocking, the Cloudflare Access tunnel, the
jump host chain (every hop through steps 1 and 2) or SOCKS5.
4. `connectHost` connects with a keyboard-interactive handler: a prompt channel
@@ -1867,48 +1976,51 @@ deciding, not the mechanism.
## The ctx surface
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
Every member is listed; the types in `@termix/plugin-sdk/backend` are the
authority for signatures.
| Member | Capability | Status |
| ------------------------------------------------ | -------------------------------------------------- | ----------------------------------- |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** |
| `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.files.dataDir` | `files:own` | **B13** |
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** |
| `ctx.secrets.seal` / `.unseal` | `secrets:own` | **C1** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.http.baseUrl(req)` | none | **C2** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** |
| `ctx.hosts.status.*` | `hosts:read` | **B16** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.channels` / `.send` | `notify:send` | **B17** |
| `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.auth.completeRedirectLogin` and the rest | `auth:provide` | **C2** |
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
| `ctx.credentials.listSshKeys` | `credentials:use` | **C6** |
| `ctx.credentials.createSshKey` | `credentials:write` | **C6** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9**, request **D0** |
| `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** |
| `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** |
| `ctx.auth.registerKeyboardInteractiveHandler` | `auth:provide` | **C3** |
| `ctx.system.*` | `system:tls` | **C7** |
| Member | Capability | What it does |
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `pluginId`, `manifest` | none | The plugin's id and parsed manifest |
| `log.debug/info/warn/error` | none | Lines in core's log, tagged with the plugin |
| `events.emit/on` | `events:core` for core topics | Topics under `plugin.<id>.*`, or core's (`user.deleted`, `user.data_wiped`, host events) |
| `kv.get/set/delete/list` | `kv:own` | Small key/value state |
| `files.dataDir()` | `files:own` | A per-plugin folder under `DATA_DIR` |
| `db.define/client/refs/persist/dialect` | `db:own` (not `dialect`) | The plugin's own tables through Drizzle; `persist` flushes SQLite to disk |
| `sync.registerEntity/recordTombstone` | none | Adds a table to remote sync, and records a delete for it |
| `registry.provide/consume/revoke` | none | The low-level named registry services sit on |
| `services.provide/get/providers` | per-service RBAC | Typed, versioned cross-plugin services, checked per call |
| `secrets.get/set/delete/seal/unseal` | `secrets:own` | The plugin's own encrypted values |
| `secrets.offer/withdraw/getShared` | per-secret RBAC | Secrets one plugin lends another |
| `http.router(options)`, `http.baseUrl(req)` | `network:serve` (router) | The router mounted at `/plugin-api/<id>/`, and the public origin for callback URLs |
| `ws.route/upgrade` | `network:serve` | Sockets at `/plugin-ws/<id>/<path>` |
| `rbac.has/hasFor/require` | own permissions (require) | The acting user's role permissions; `require` is route middleware |
| `settings.get/set/getUser/setUser/getHost/setHost/listHostValues/getAll/onChange/onValidate` | `hosts:read` (listHostValues) | The plugin's admin, user and host settings from its manifest |
| `settings.readCore(key)` | `settings:read-core` | One of the core keys in `CORE_SETTINGS_ALLOWLIST` |
| `disposables.add` | none | Extra cleanup on deactivate |
| `capabilities.has/require` | the named capability | A generic check for privileged code no ctx member wraps |
| `hosts.list/get/checkAccess` | `hosts:read` | Hosts the acting user can see, without secrets |
| `hosts.create/update/delete/listOwned/share/listUsers/listRoles` | `hosts:write` | Host changes and sharing on the user's behalf |
| `hosts.trackSession/recordActivity` | `hosts:read` | The online indicator and a recent activity entry |
| `hosts.status.get/check/reportLogin/registerPort` | `hosts:read` | Core's reachability status |
| `ssh.connect/withConnection/jumpChain` | `ssh:connect`, `credentials:use` | Connections through core's one connect pipeline |
| `ssh.resolveHost/prepare/openTransport/classifyKeyboardInteractive/autoResponses/startInteraction/cancelInteraction` | `ssh:connect`, `credentials:use` | The lower level, for a transport with its own prompt flow |
| `ssh.poolKey/dropPooled/requiresSecret/supportsBackground` | `ssh:connect` | Pool housekeeping and questions for an auth type's provider |
| `auth.registerLoginMethod/registerSecondFactor/recordEnrollment/removeEnrollment` | `auth:provide` | Login methods and second factors |
| `auth.completeRedirectLogin/revokeSessions/loginRateLimit/countLinkedUsers` | `auth:provide` | What a login method needs from core's session and provisioning code |
| `auth.registerSshAuthProvider/registerKeyboardInteractiveHandler` | `auth:provide` | SSH auth types and keyboard-interactive handlers |
| `credentials.listSshKeys` / `.createSshKey` | `credentials:use` / `credentials:write` | The user's saved SSH keys |
| `credentials.resolveHostProtocol` | `credentials:read` | A host's RDP, VNC or Telnet login |
| `credentials.registerSecretResolver` | `auth:provide` | Resolves external secret references in host and credential fields |
| `desktop.openIsolatedWindow/launchNativeRdp/available` | `desktop:window` (not `available`) | Electron windows and the native RDP client |
| `audit.record` | none | An audit line under the plugin's own action name |
| `schedule.every/after` | none | Timers that stop on deactivate and never overlap |
| `notify.channels/send` | `notify:send` | Core's notification channels, as the acting user |
| `fetch(url, init)` | `network:outbound` | Outbound HTTP through core's SSRF guard; `allowPrivateHosts` hosts also go through the configured proxy |
| `process.run/ensureBinary` | `process:spawn` (+ `network:outbound` to download) | Programs on the server |
| `system.tlsStatus/writeTlsCertificate/reloadTls/publishHttpChallenge/registerTlsRenewer` | `system:tls` | The server certificate |
| `asUser(userId, fn)` | none, always audited | Background work as a user |
| `currentActor()` | none | The acting user for the current call |
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
generic check for a capability no other ctx member wraps. Unlike every other
@@ -1948,14 +2060,14 @@ connect pipeline instead of importing ssh2 helpers from core:
has no auth code of its own left.
- `requiresSecret(authType)` and `supportsBackground(authType)` ask the
provider.
- **B6** added `"file-manager"` and `"file-transfer"` to `PluginSshPurpose`
(core's `SshConnectPurpose` already had them; the SDK type had lagged),
since the file-manager plugin's interactive connect route and its dedicated
transfer sessions both need their own keepalive/timeout defaults rather than
falling back to the generic `"plugin"` purpose.
- **B10** added `"tmux"` to `PluginSshPurpose` the same way (core's
`SshConnectPurpose` already had it), for the tmux-monitor plugin's pooled
connection and the `tmux.sessions` service it provides to the terminal.
- `purpose` is a free label (`"terminal"`, `"docker"`) that auth providers
see in `connectOptions`. **D1** moved the keepalive, timeout and
environment defaults onto `profile`: `"terminal"` (an interactive shell),
`"session"` (a long-lived browsing session such as the file manager or
Docker), `"stream"` (a long transfer or console), `"forward"` (port
forwarding) or `"background"`, the default, for short exec work. Core's own
jump chain uses a `"jump"` profile plugins cannot pick. Core no longer keeps
a list of purposes, so it names no plugin.
- **B9** added what an interactive transport needs, for the terminal:
`resolveHost(hostId, { syncId? })` (the host with secrets, by sync id first,
audited), the `"terminal"` purpose, `interactive` and `hostKeySocket` on
@@ -2349,7 +2461,8 @@ keeps working unchanged.
what Docker and electron-builder package. `npm run build`, `build:backend` and
`dev:backend` all call it.
TypeScript only type-checks (`tsconfig.plugins.json`, `noEmit`); esbuild does
TypeScript only type-checks (`tsconfig.plugins.backend.json` and
`tsconfig.plugins.frontend.json`, `noEmit`); esbuild does
every emit. Nothing is written next to a source file.
### Host-provided packages
@@ -2365,9 +2478,8 @@ and that is bundled into its output.
the terminal's image upload loads it on first use).
**Frontend**: `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`,
`@termix/plugin-sdk/ui` and `@termix/legacy-core/*`, all resolved through the
import map (see UI). Everything else, including `lucide-react`, `axios`,
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend` and
`@termix/plugin-sdk/ui`, all resolved through the import map (see UI). Everything else, including `lucide-react`, `axios`,
`cytoscape` and `guacamole-common-js`, is bundled into the plugin that uses
it.
@@ -2673,10 +2785,10 @@ install (workspaces leaves out its `last_session` row).
2. Register everything in `activate(app)`: rail item (with `permission` when
there is one), panel, tabs, host editor sections, actions and slot
contributions. Use `app.onDispose` for timers.
3. Remove the shell's references: imports from the feature, hardcoded ids and
switch cases, and the matching entries in
`scripts/shell-plugin-id-allowlist.json`. `node scripts/check-shell-plugin-ids.cjs`
must pass.
3. Remove core's references: imports from the feature, hardcoded ids and
switch cases. Where core still needs the feature, name a slot, action or
component id in core's own vocabulary and let the plugin fill it.
`node scripts/check-core-plugin-ids.cjs` must pass.
4. The rail item appears in Appearance > Sidebar > Navigation on its own
because it is registered; confirm it there.
5. No hardcoded text (a relative time is a translated string), no em dashes,
@@ -2715,14 +2827,7 @@ install (workspaces leaves out its `last_session` row).
4. `cd plugins/<id> && npx vitest run` while working; `npm run test:plugins` at
the end.
### 14. Boundary allowlist
`node scripts/check-plugin-boundaries.cjs --write`, then
`git diff scripts/plugin-boundary-allowlist.json`: the plugin's entries are
gone and **no other line was added**. Update the counts in the table under
[Legacy core imports](#legacy-core-imports-the-debt-d1-removes).
### 15. Build, test and check by hand
### 14. Build, test and check by hand
```bash
npm run build:sdk
@@ -2745,91 +2850,3 @@ Then, with the app running:
views show the "needs the plugin" placeholder while off and come back after.
5. On Postgres or MySQL if available: `npm run verify:dialect`, then boot.
6. Update this document for anything the step added to the contract.
---
## Legacy core imports: the debt D1 removes
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
(B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2),
opkssh and warpgate (C3), step-ca (C4), vault (C5), termix-identity (C6),
acme-ssl (C7),
which import nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
and rewrites to the compiled output path (`../../../backend/backend/...`, or
`../../../backend/types/...` for shared types). The rewrite is output-relative,
so a source file at any nesting collapses to the same prefix. D1 deletes that
file.
Frontends do the same with `@/`: in the browser build, `@/x` and
`../src/ui/x` become the bare `@termix/legacy-core/ui/x` (or
`@termix/legacy-core/types/x`), which core's Vite build exposes as a shared
module so plugin and shell use one instance of each core module. The CLI
refuses the rewrite outside a Termix checkout. Every such import is counted
in the `plugin-frontend-to-core` direction below.
This is expected and temporary. The SDK does not yet expose what they need:
the SSH connection pool, host resolution, the repository layer, the shared
component library. D1 removes the debt once A3 through A8 have given them
supported APIs to move onto.
What the lint fence enforces today, in `eslint.config.mjs`:
| Direction | Severity | Offenders | Emptied by |
| ------------------------------------------------ | --------- | --------- | ---------- |
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 51 files | D1 |
| A plugin importing core by relative path | Warning | 3 files | D1 |
| A plugin importing another plugin's source | Warning | 0 files | - |
A warning does not fail a build, so the counts are held by
`scripts/check-plugin-boundaries.cjs`, run by `npm run lint`. Every offender is
listed in `scripts/plugin-boundary-allowlist.json`: a new one fails, and so
does an entry that stopped being an offender, so the list shrinks as each step
lands rather than rotting. `--write` regenerates it. D1 empties it and the two
plugin-side warnings become errors.
The two plugin-backend directions use different rule names
(`no-restricted-imports` and `@typescript-eslint/no-restricted-imports`)
because flat config replaces a rule's options rather than merging them, so one
rule cannot carry both severities on the same files.
Known specifics:
- **B17** removed the last plugin-to-plugin import: ai's proposal executor
and automation read tools call automations through the `automations.access`
service, and automations is an optional dependency of ai rather than a hard
one.
- **B16** moved host-metrics onto the SDK: it imports nothing from core or
automations. Automations reaches it through the `host-metrics.viewers`
service and its `plugin.host-metrics.*` events, and core reaches it through
the `dashboard.hostMetrics` and `homepage.hostMetrics` slots and the
`host-metrics.disk` action. Core's `hosts/metrics-shared/` is gone: ai,
automations, proxmox and tailscale use `@termix/plugin-sdk/host-commands`.
- Core feature servers that are not plugins yet still own ports: dashboard
30006 and homepage 30012. Each keeps its nginx block until its own Phase B
step. No plugin owns a port any more (A4). **B6** moved file-manager off
port 30004 onto `/plugin-api/file-manager/`, **B7** moved tunnels off port
30003 onto `/plugin-api/tunnels/` and `/plugin-ws/tunnels/c2s/stream`,
**B10** moved tmux monitoring off port 30010, and **B11** moved serial off
port 30011 onto `/plugin-ws/serial/console`.
- **B9** moved the terminal, its session manager, the local terminal and the
command history panel into ssh-terminal, which imports nothing from core.
The file manager's terminal window renders the `terminal.view` component
instead of importing the core terminal.
- Host data columns (`enableFileManager` and so on) stay in core types until
Phase B moves them. Only UI branches on them left the shell. **B15** moved
docker's `enableDocker` and `docker_config` (now `containerRuntime`) into
its host settings. **B14** moved
remote desktop's (`enableRdp`, `enableVnc`, `enableTelnet`, the three ports,
`rdpSecurity`, `rdpIgnoreCert`, `guacamoleConfig`) into its host settings;
the protocol logins stay core host fields.
- **B14** moved remote desktop onto the SDK: it imports nothing from core,
owns no module state, and its guacamole-lite server is built in `activate`
with its own signal handlers removed (guacamole-lite installs SIGTERM and
SIGINT handlers in its constructor). guacd is always an external service,
so it does not use `ctx.process`; its address is an admin setting.
+29
View File
@@ -154,3 +154,32 @@ with a real install, a real device or a repo outside this one.
copied in without a restart.
- A password user who signs in for the first time after the upgrade keeps
their TOTP (the copy runs once their keys unlock).
- D1 changes to check by hand: Quick Connect lists Tailscale next to the
core auth types and connects with it, and its "Connect to Files" button
comes from the file manager; the admin "manage user" panel's Snippets tab
(add, edit, delete a snippet for another user); the dashboard's active
tunnel counter and its click; the tab bar's file manager button; the
secret reference hint and manager in the host and credential editors;
Proxmox guest discovery and import (now its own `/plugin-api/proxmox/import`
route); the Tailscale device list behind an outbound proxy and against a
LAN Headscale; dragging a local file into a host in the desktop app (its
transfer URL moved off the dead port 30004); a per-user database export and
import that carries file manager bookmarks; the Simple preset hiding plugin
rail items except Snippets.
## Left for later
- 3.0.0: drop the migrated 2.8 settings rows (`guac_url`, `step_ca_url`,
`global_metrics_interval` and the rest). The upgrade moves leave them in
place so a downgrade to 2.8 still works; nothing reads them in 2.9.
- Plugin repo split: knip still reports about 150 unused exports inside
plugins (constants exported for readability, `tables.ts` exports the CLI
reads, test-only helpers). Prune them per plugin when it moves to its own
repo; core and the SDK are clean.
- Plugin OpenAPI docs: plugins carry `@openapi` comments but
`src/backend/utils/swagger.ts` only scans core routes, so
`npm run generate:openapi` leaves every `/plugin-api/` route out.
- Three backend tests sit next to their source, against the test layout
rule: `src/backend/hosts/host-session-status.test.ts`,
`jump-host-proxy.test.ts` and `ssh-keepalive.test.ts`. Move them under
`src/backend/tests/hosts/`.
+1 -21
View File
@@ -2,7 +2,6 @@ import fs from "node:fs";
import path from "node:path";
import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
import { legacyCoreImports } from "../lib/legacy-core-imports.mjs";
import { staticUrlImports } from "../lib/static-url-imports.mjs";
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
@@ -18,17 +17,6 @@ const FRONTEND_ENTRIES = [
"src/frontend/index.js",
];
/** Whether the plugin sits in a Termix checkout, i.e. is a bundled plugin. */
function insideTermixCheckout(cwd) {
let dir = path.resolve(cwd);
for (;;) {
if (fs.existsSync(path.join(dir, "src", "ui", "plugin-host"))) return true;
const parent = path.dirname(dir);
if (parent === dir) return false;
dir = parent;
}
}
export async function build({ cwd }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
@@ -59,7 +47,6 @@ export async function build({ cwd }) {
// node_modules import instead, resolved at runtime like a host-provided
// package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
plugins: [legacyCoreImports({ pluginId, platform: "node" })],
});
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
@@ -75,14 +62,7 @@ export async function build({ cwd }) {
sourcemap: true,
logLevel: "warning",
external: FRONTEND_EXTERNALS,
plugins: [
staticUrlImports({ outDir }),
legacyCoreImports({
pluginId,
platform: "browser",
insideTermix: insideTermixCheckout(cwd),
}),
],
plugins: [staticUrlImports({ outDir })],
});
}
@@ -42,6 +42,4 @@ export const FRONTEND_EXTERNALS = [
"sonner",
"@termix/plugin-sdk/frontend",
"@termix/plugin-sdk/ui",
// Legacy: the shell's own modules, see legacy-core-imports.mjs. D1 removes.
"@termix/legacy-core/*",
];
@@ -1,68 +0,0 @@
/**
* Legacy core imports: the debt D1 removes.
*
* Plugin code predates the SDK and still reaches core, and in one case a
* sibling plugin, by relative path. Those specifiers must stay out of the
* bundle and be rewritten to where the built server puts the target.
*
* The rewrite is output-relative, not source-relative. Every plugin bundles
* to plugins/<id>/dist/backend.js, which sits at the same depth the old
* per-file output did, so one prefix covers a source file at any nesting.
*
* Delete this file in D1, once every plugin reaches core through the SDK.
*/
import { legacyCoreSpecifier } from "./legacy-core-specifier.mjs";
// "../../../src/backend/" or "../../../../src/backend/", any depth.
const CORE_BACKEND = /^(?:\.\.\/)+src\/backend\//;
// Shared types. Mostly erased at compile, but automations imports a real
// value (AUTOMATION_DEFINITION_VERSION), so this has to resolve at runtime.
const CORE_TYPES = /^(?:\.\.\/)+src\/types\//;
const CORE_UI = /^(?:\.\.\/)+src\/ui\//;
/** dist/plugins/<id>/dist/backend.js -> dist/backend/... */
const COMPILED_CORE = "../../../backend/backend/";
const COMPILED_TYPES = "../../../backend/types/";
const CORE_ALIAS = /^@\//;
export function legacyCoreImports({ pluginId, platform, insideTermix = true }) {
return {
name: "legacy-core-imports",
setup(build) {
// The shell's own modules, reached as "@/..." or by relative path.
// Bundling them would give the plugin a second copy of the shell's
// contexts and API client, so each becomes a bare specifier the page's
// import map resolves to the shell's own module.
if (platform === "browser") {
const toLegacy = (args) => {
const specifier = legacyCoreSpecifier(args.path);
if (!specifier) return null;
if (!insideTermix) {
return {
errors: [
{
text: `${pluginId}: "${args.path}" reaches Termix core. Only plugins built inside a Termix checkout may; use @termix/plugin-sdk instead.`,
},
],
};
}
return { path: specifier, external: true };
};
build.onResolve({ filter: CORE_ALIAS }, toLegacy);
build.onResolve({ filter: CORE_UI }, toLegacy);
build.onResolve({ filter: CORE_TYPES }, toLegacy);
}
build.onResolve({ filter: CORE_BACKEND }, (args) => ({
path: args.path.replace(CORE_BACKEND, COMPILED_CORE),
external: true,
}));
build.onResolve({ filter: CORE_TYPES }, (args) => ({
path: args.path.replace(CORE_TYPES, COMPILED_TYPES),
external: true,
}));
},
};
}
@@ -1,62 +0,0 @@
/**
* Legacy core modules a bundled plugin frontend still imports.
*
* Bundled plugin frontends predate the SDK and import the shell's own modules
* as "@/..." (or, in a couple of places, by relative path into src/). Bundling
* those would give the plugin a second copy of the shell's contexts and API
* client, so the CLI rewrites each one to a bare specifier under
* "@termix/legacy-core/" and core's build exposes exactly that module through
* the import map. Both sides use this one normalizer so they cannot disagree.
*
* Only plugins built inside a Termix checkout may do this. D1 deletes it.
*/
export const LEGACY_CORE_PREFIX = "@termix/legacy-core/";
const EXTENSION = /\.(tsx|ts|jsx|js|mjs)$/;
function clean(rest) {
return rest.replace(EXTENSION, "").replace(/\/+$/, "");
}
/**
* "@/components/button.tsx" -> "@termix/legacy-core/ui/components/button"
* "@/types" -> "@termix/legacy-core/types"
* "@/types/connection-log" -> "@termix/legacy-core/types/connection-log"
* "../../../src/ui/x.tsx" -> "@termix/legacy-core/ui/x"
* Anything else -> null
*/
export function legacyCoreSpecifier(specifier) {
if (specifier.startsWith("@/")) {
const rest = clean(specifier.slice(2));
if (rest === "types" || rest.startsWith("types/")) {
return LEGACY_CORE_PREFIX + rest;
}
return `${LEGACY_CORE_PREFIX}ui/${rest}`;
}
const relative = /^(?:\.\.\/)+src\/(ui|types)(\/.*)?$/.exec(specifier);
if (relative) {
return LEGACY_CORE_PREFIX + clean(`${relative[1]}${relative[2] ?? ""}`);
}
return null;
}
/**
* Every value import in a source file that names a legacy core module.
* Type-only imports are skipped: they are erased and never reach the map.
*/
export function findLegacyCoreImports(source) {
const found = new Set();
const patterns = [
/^\s*(?:import|export)\s+(?!type\s)[^'";]*?\sfrom\s+["']([^"']+)["']/gm,
/^\s*import\s+["']([^"']+)["']/gm,
/import\(\s*["']([^"']+)["']\s*\)/g,
];
for (const pattern of patterns) {
for (const match of source.matchAll(pattern)) {
const normalized = legacyCoreSpecifier(match[1]);
if (normalized) found.add(normalized);
}
}
return found;
}
@@ -825,6 +825,26 @@
"type": "string",
"pattern": "^/[a-z][a-z0-9-]*(/[A-Za-z0-9_.-]+)+$"
}
},
"legacyRedirects": {
"type": "array",
"description": "Old URLs outside the plugin's namespace, redirected to a path in its router with the rest of the path and the query kept.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["from", "to"],
"properties": {
"from": {
"type": "string",
"pattern": "^(/[A-Za-z0-9_.-]+)+$"
},
"to": {
"type": "string",
"pattern": "^(/[A-Za-z0-9_.-]+)+$"
},
"status": { "enum": [307, 308] }
}
}
}
}
},
+20 -13
View File
@@ -737,19 +737,17 @@ export interface PluginSshHost {
}
/** Picks keepalive and timeout defaults. "plugin" is the generic one. */
export type PluginSshPurpose =
| "plugin"
| "docker"
| "docker-console"
| "metrics"
| "proxmox"
| "fleet"
| "remote-desktop"
| "tunnel"
| "file-manager"
| "file-transfer"
| "terminal"
| "tmux";
/** What a connection is for, as a free label. Auth providers see it. */
export type PluginSshPurpose = string;
/**
* Keepalive, timeout and environment defaults for a kind of connection.
* terminal: an interactive shell. session: a long-lived browsing session.
* stream: a long transfer or console. forward: port forwarding. background
* (the default): short exec work such as polling.
*/
export type PluginSshProfile =
"terminal" | "session" | "stream" | "forward" | "background";
export type PluginSshAuthOutcome =
| { status: "ready" }
@@ -868,6 +866,8 @@ export interface PluginSshPromptChannel {
export interface PluginSshConnectOptions {
purpose?: PluginSshPurpose;
/** Keepalive and timeout defaults. Defaults to "background". */
profile?: PluginSshProfile;
/** Overall time to reach "ready". Defaults to 30s. */
timeoutMs?: number;
/** Without one, the stored password answers password prompts. */
@@ -956,6 +956,8 @@ export interface PluginSsh {
host: PluginSshHost,
options: {
purpose?: PluginSshPurpose;
/** Keepalive and timeout defaults. Defaults to "background". */
profile?: PluginSshProfile;
client: unknown;
/** Server-side host id when it differs from host.id. */
serverHostId?: number;
@@ -1079,6 +1081,11 @@ export interface PluginSshAuthProvider {
requiresSecret?: boolean;
/** Can connect unattended. Defaults to true. */
supportsBackground?: boolean;
/**
* Works for a host that was never saved (nothing in host settings, no host
* id to key a cache on), so Quick Connect offers it. Default false.
*/
quickConnect?: boolean;
/** Interaction name its outcomes use, for startInteraction routing. */
interaction?: string;
connectOptions?: (
+32
View File
@@ -128,6 +128,11 @@ export interface RailItemContribution {
kind?: "panel" | "tab";
/** Users may hide it from Appearance > Sidebar > Navigation. Default true. */
hideable?: boolean;
/**
* Stays visible in the Simple interface preset, which hides every other
* plugin rail item. For the one or two things a new user reaches for.
*/
simplePreset?: boolean;
/** Can also open as a full-width tab. */
promotable?: boolean;
/** Can open in the right dock. */
@@ -302,6 +307,11 @@ export interface HostActionContribution {
copyUrlView?: string;
/** Where a host overview (the dashboard's host status list) sends a click. */
overview?: boolean;
/**
* Offered as a button in Quick Connect, next to the default connect button,
* for an address that is never saved. Opens tabType.
*/
quickConnect?: boolean;
/**
* Position in the host row. Core: Files 20, Tunnel 40, Tmux 70. Connect
* actions default to 100 and sit after a separator.
@@ -709,6 +719,8 @@ export interface SshAuthTypeInfo {
credentialType: boolean;
/** Can connect unattended, for polling. */
supportsBackground: boolean;
/** Offered in Quick Connect, for a host that is never saved. */
quickConnect: boolean;
}
/**
@@ -825,6 +837,18 @@ export interface PluginCoreApi {
setHostAutoTmux: (hostId: number, autoTmux: boolean) => Promise<void>;
/** A browser-side UI preference (a cookie, or the desktop app's store). */
getClientPreference: (name: string) => string | undefined;
/** Every host the user can see, fetched now rather than from the shell's cache. */
listHosts: () => Promise<PluginHostRecord[]>;
/** The user's stored credentials, without their secrets. */
listCredentials: () => Promise<PluginCredentialSummary[]>;
}
/** A stored credential as a picker needs it. Secrets never leave core. */
export interface PluginCredentialSummary {
id: number;
name: string;
username?: string;
authType?: string;
}
/** What a recent-activity entry opens and how it is labelled. */
@@ -972,6 +996,14 @@ export function getClientPreference(name: string): string | undefined {
return requireHost().core.getClientPreference(name);
}
export function listHosts(): Promise<PluginHostRecord[]> {
return requireHost().core.listHosts();
}
export function listCredentials(): Promise<PluginCredentialSummary[]> {
return requireHost().core.listCredentials();
}
export function usePluginApi(): PluginApiClient {
const bridge = requireHost();
return bridge.getApi(bridge.usePluginId());
+58 -2
View File
@@ -62,7 +62,6 @@ export type SystemRoleName = (typeof SYSTEM_ROLE_NAMES)[number];
*/
export const RESERVED_PERMISSION_PREFIXES = [
"hosts",
"snippets",
"credentials",
"admin",
] as const;
@@ -324,6 +323,23 @@ export interface PluginHttpContribution {
* a clash.
*/
legacyPaths?: string[];
/**
* Old URLs outside the plugin's namespace that something outside Termix
* still sends people to (an identity provider's callback, a published
* resolver URL). A request at `from`, or under it, is redirected to `to`
* under /plugin-api/<id>/ with the rest of the path and the query kept.
* Core routes win a clash.
*/
legacyRedirects?: PluginLegacyRedirect[];
}
export interface PluginLegacyRedirect {
/** An absolute path, e.g. "/users/oidc/callback". */
from: string;
/** A path in this plugin's router, e.g. "/callback". */
to: string;
/** 307 (the default) or 308 for a permanent move. */
status?: 307 | 308;
}
export interface PluginManifest {
@@ -869,6 +885,40 @@ function validateUiPresets(value: unknown, errors: string[]): void {
}
const LEGACY_PATH_PATTERN = /^\/[a-z][a-z0-9-]*(\/[A-Za-z0-9_.-]+)+$/;
const REDIRECT_PATH_PATTERN = /^(\/[A-Za-z0-9_.-]+)+$/;
const RESERVED_PREFIXES = ["/plugin-api", "/plugin-ws", "/plugin-assets"];
function validateLegacyRedirects(value: unknown, errors: string[]): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push('Field "contributes.http.legacyRedirects" must be an array');
return;
}
value.forEach((entry, index) => {
const at = `contributes.http.legacyRedirects[${index}]`;
if (!isPlainObject(entry)) {
errors.push(`${at} must be an object`);
return;
}
rejectUnknown(entry, ["from", "to", "status"], at, errors);
const { from, to, status } = entry;
if (typeof from !== "string" || !REDIRECT_PATH_PATTERN.test(from)) {
errors.push(`${at}.from must be an absolute path`);
} else if (
RESERVED_PREFIXES.some(
(prefix) => from === prefix || from.startsWith(`${prefix}/`),
)
) {
errors.push(`${at}.from cannot be under ${from.split("/")[1]}`);
}
if (typeof to !== "string" || !REDIRECT_PATH_PATTERN.test(to)) {
errors.push(`${at}.to must be a path in the plugin's router`);
}
if (status !== undefined && status !== 307 && status !== 308) {
errors.push(`${at}.status must be 307 or 308`);
}
});
}
function validateHttpContribution(
value: unknown,
@@ -880,7 +930,13 @@ function validateHttpContribution(
errors.push('Field "contributes.http" must be an object');
return;
}
rejectUnknown(value, ["legacyPaths"], "contributes.http", errors);
rejectUnknown(
value,
["legacyPaths", "legacyRedirects"],
"contributes.http",
errors,
);
validateLegacyRedirects(value.legacyRedirects, errors);
const paths = value.legacyPaths;
if (paths === undefined) return;
if (!Array.isArray(paths)) {
+3 -1
View File
@@ -14,7 +14,7 @@ import type {
PluginDisposables,
PluginLogger,
} from "./backend.js";
import type { TermixApp } from "./frontend.js";
import type { HostActionContribution, TermixApp } from "./frontend.js";
import type { PluginManifest } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
import type {
@@ -1967,6 +1967,8 @@ export interface RenderedPluginApp {
tabs: () => string[];
panels: () => string[];
hostActions: () => Array<{ id: string; tabType?: string }>;
/** This plugin's host actions that apply to `host`, as the shell sees them. */
hostActionsFor: (host: unknown) => HostActionContribution[];
hostProtocols: () => string[];
hostEditorSections: () => string[];
dashboardCards: () => string[];
+3
View File
@@ -112,6 +112,7 @@ const offline = () =>
pluginId: "core",
credentialType: type === "password" || type === "key",
supportsBackground: type !== "none",
quickConnect: true,
})),
loaded: true,
}),
@@ -138,6 +139,8 @@ const offline = () =>
getCustomKeybindings: async () => [],
setHostAutoTmux: async () => {},
getClientPreference: () => undefined,
listHosts: async () => [],
listCredentials: async () => [],
},
};
+2
View File
@@ -75,6 +75,8 @@ export function pluginVitestConfig(
"plugin-host",
"testing-host.tsx",
),
// Core's own modules behind sdk-ui and the test host import each
// other this way. Plugin code may not (eslint refuses it).
"@/types": path.join(repoRoot, "src", "types"),
"@": path.join(repoRoot, "src", "ui"),
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
-6
View File
@@ -1,10 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+1
View File
@@ -250,6 +250,7 @@ export async function startConnect(
ctx.ssh
.connect<Client>(host, {
purpose: "docker",
profile: "session",
timeoutMs: CONNECT_TIMEOUT_MS,
prompt: { ask },
})
+1
View File
@@ -200,6 +200,7 @@ export function registerConsole(ctx: PluginContext, log: DockerLogger): void {
const { client, dispose } = await ctx.ssh.connect<Client>(host, {
purpose: "docker-console",
profile: "stream",
timeoutMs: 65_000,
});
if (ws.readyState !== WebSocket.OPEN) {
+2 -2
View File
@@ -92,7 +92,7 @@ export function registerServices(ctx: PluginContext, log: DockerLogger): void {
const { runtime } = await readDockerHostSettings(ctx, hostId);
const output = await ctx.ssh.withConnection<string, Client>(
hostId,
{ pool: POOL, purpose: "docker" },
{ pool: POOL, purpose: "docker", profile: "session" },
(client) => execOnClient(client, command(runtime)),
);
return handle(output);
@@ -161,7 +161,7 @@ export function registerServices(ctx: PluginContext, log: DockerLogger): void {
const { runtime } = await readDockerHostSettings(ctx, watch.hostId);
const output = await ctx.ssh.withConnection<string, Client>(
host,
{ pool: POOL, purpose: "docker" },
{ pool: POOL, purpose: "docker", profile: "session" },
(client) =>
execOnClient(
client,
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
@@ -0,0 +1,119 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export type ConnectionStage =
| "dns"
| "tcp"
| "handshake"
| "auth"
| "connected"
| "connection"
| "error"
| "proxy"
| "jump"
| "sftp_connecting"
| "sftp_auth"
| "sftp_connected";
export type LogEntry = {
id: string;
timestamp: Date;
type: "info" | "success" | "warning" | "error";
stage: ConnectionStage;
message: string;
details?: Record<string, unknown> | string;
};
export function createConnectionLog(
type: LogEntry["type"],
stage: ConnectionStage,
message: string,
details?: Record<string, unknown>,
): Omit<LogEntry, "id" | "timestamp"> {
return { type, stage, message, details };
}
function normalizeHostAddress(value: unknown): string {
if (typeof value !== "string") return "";
return value
.replace(/^\[|\]$/g, "")
.trim()
.toLowerCase();
}
/**
* Whether a numeric host id resolved to a different machine than the client
* meant. Ids from the desktop app and a sync server drift apart, and the
* resolved row supplies the credentials, so a mismatch must be refused.
*/
export function hostAddressMismatch(
clientAddress: unknown,
resolvedAddress: unknown,
): boolean {
const resolved = normalizeHostAddress(resolvedAddress);
if (!resolved) return false;
return resolved !== normalizeHostAddress(clientAddress);
}
export function resolveServerHostId(
clientHostId: number | null,
resolvedHost: { id?: unknown } | null,
): number | null {
return typeof resolvedHost?.id === "number" ? resolvedHost.id : clientHostId;
}
export class HostAddressMismatchError extends Error {
constructor() {
super(
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
"The host ids on this device and on the sync server have drifted apart. " +
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.',
);
this.name = "HostAddressMismatchError";
}
}
export class HostNotOnThisServerError extends Error {
constructor() {
super(
"This host does not exist on the sync server, so the connection was refused. " +
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.',
);
this.name = "HostNotOnThisServerError";
}
}
type Meta = Record<string, unknown>;
export interface FileLogger {
info: (message: string, meta?: Meta) => void;
success: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
error: (message: string, error?: unknown, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
if (!meta || Object.keys(meta).length === 0) return message;
try {
return `${message} ${JSON.stringify(meta)}`;
} catch {
return message;
}
}
/** ctx.log takes a plain message; this keeps the structured fields readable. */
export function createFileLogger(log: PluginLogger): FileLogger {
return {
info: (message, meta) => log.info(withMeta(message, meta)),
success: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, error, meta) => {
if (error instanceof Error) {
log.error(withMeta(message, meta), error);
} else if (error && typeof error === "object" && !meta) {
log.error(withMeta(message, error as Meta));
} else {
log.error(withMeta(message, meta));
}
},
};
}
+40 -60
View File
@@ -1,9 +1,5 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import express, { type Router } from "express";
import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
import { getErrorMessage } from "./error-message.js";
@@ -15,30 +11,17 @@ import {
} from "./transfer-tuning.js";
const TRANSFER_PROFILES_KEY = "transfer-profiles";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../../../src/backend/utils/audit-logger.js";
import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js";
import {
resolveHostById,
resolveHostBySyncId,
} from "../../../../src/backend/hosts/host-resolver.js";
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
import { JumpHostChainError } from "../../../../src/backend/hosts/jump-host-chain.js";
import {
createConnectionLog,
createFileLogger,
hostAddressMismatch,
HostAddressMismatchError,
HostNotOnThisServerError,
resolveServerHostId,
} from "../../../../src/backend/hosts/host-identity.js";
import type {
LogEntry,
ConnectionStage,
} from "../../../../src/types/connection-log.js";
type ConnectionStage,
type FileLogger,
type LogEntry,
} from "./host-identity.js";
import {
ChannelOpenSerializer,
execChannel,
@@ -66,7 +49,17 @@ import {
import { createFilesService } from "./service.js";
import { tables } from "./tables.js";
const fileLogger = logger;
let fileLogger: FileLogger = createFileLogger(console);
// Shape of a resolved host as this route reads it; core fills more fields
// than PluginSshHost names.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type LooseHost = PluginSshHost & Record<string, any>;
// Core's jump chain failure, matched by name so no core class is imported.
function isJumpHostChainError(error: unknown): boolean {
return error instanceof Error && error.name === "JumpHostChainError";
}
/** Cosmetic only: whether the proxy step is worth a connection-log line. */
function hasSocks5Config(host: PluginSshHost): boolean {
@@ -115,6 +108,7 @@ function assertResolvedHost(
}
export async function activate(ctx: PluginContext) {
fileLogger = createFileLogger(ctx.log);
setPluginSsh(ctx.ssh);
setPluginCtx(ctx);
ctx.disposables.add(() => setPluginSsh(null));
@@ -276,7 +270,7 @@ export async function activate(ctx: PluginContext) {
throw new Error("Cannot open transfer connection: unknown host");
}
const host = await resolveHostById(hostId, userId);
const host = await ctx.asUser(userId, () => ctx.ssh.resolveHost(hostId));
if (!host) {
throw new Error("Host not found for transfer connection");
}
@@ -304,6 +298,7 @@ export async function activate(ctx: PluginContext) {
host as unknown as PluginSshHost,
{
purpose: "file-transfer",
profile: "stream",
timeoutMs: 60000,
overrides: { tcpKeepAliveInitialDelay: 5000 },
},
@@ -600,9 +595,9 @@ export async function activate(ctx: PluginContext) {
let resolvedSocks5ProxyChain = socks5ProxyChain;
const resolveFrom = async () => {
const resolvedHost = hostSyncId
? await resolveHostBySyncId(hostSyncId, userId)
: await resolveHostById(hostId, userId);
const resolvedHost = (await ctx.ssh.resolveHost(hostId, {
syncId: hostSyncId,
})) as LooseHost | null;
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
if (!resolvedHost) return;
resolvedIp = resolvedHost.ip;
@@ -690,7 +685,9 @@ export async function activate(ctx: PluginContext) {
let serverHostId = hostId;
if (hostSyncId && userId) {
const resolvedHost = await resolveHostBySyncId(hostSyncId, userId);
const resolvedHost = await ctx.ssh.resolveHost(hostId, {
syncId: hostSyncId,
});
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
serverHostId = resolveServerHostId(hostId, resolvedHost) ?? hostId;
}
@@ -744,6 +741,7 @@ export async function activate(ctx: PluginContext) {
const ssh = pluginSsh();
const prepared = await ssh.prepare(connectTarget, {
purpose: "file-manager",
profile: "session",
client,
serverHostId,
log: (level, message) =>
@@ -850,17 +848,13 @@ export async function activate(ctx: PluginContext) {
scheduleSessionCleanup(sessionId);
if (userId) {
const { ipAddress, userAgent } = getRequestMeta(req);
void (async () => {
await logAudit({
userId,
username: await getAuditUsername(userId),
await ctx.audit.record({
request: req,
action: "file_manager_connect",
resourceType: "host",
resourceId: serverHostId ? String(serverHostId) : undefined,
resourceName: `${username}@${ip}:${port}`,
ipAddress,
userAgent,
success: true,
});
})();
@@ -1169,17 +1163,14 @@ export async function activate(ctx: PluginContext) {
hostId,
});
const stage =
error instanceof JumpHostChainError || jumpHostCount > 0
? "jump"
: "proxy";
isJumpHostChainError(error) || jumpHostCount > 0 ? "jump" : "proxy";
connectionLogs.push(
createConnectionLog("error", stage, getErrorMessage(error)),
);
return res.status(500).json({
error:
error instanceof JumpHostChainError
? `Failed to connect through jump hosts: ${error.message}`
: getErrorMessage(error),
error: isJumpHostChainError(error)
? `Failed to connect through jump hosts: ${error.message}`
: getErrorMessage(error),
connectionLogs,
});
}
@@ -1195,24 +1186,13 @@ export async function activate(ctx: PluginContext) {
port: number,
): Promise<void> {
try {
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
const hostName =
host?.userId === userId && host.name
? host.name
: `${username}@${ip}:${port}`;
const authManager = AuthManager.getInstance();
await axios.post(
"http://localhost:30006/activity/log",
{ type: "file_manager", hostId, hostName },
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(userId)}`,
},
},
const host = await ctx.hosts.get(hostId);
await ctx.asUser(userId, () =>
ctx.hosts.recordActivity(
hostId,
"file_manager",
host?.name || `${username}@${ip}:${port}`,
),
);
} catch (error) {
fileLogger.warn("Failed to log file manager activity", {
@@ -15,7 +15,6 @@ import {
permanentlyDeleteTrashItem,
restoreTrashItem,
} from "./trash-service.js";
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
type FileOperationRoutesDeps = {
ctx: PluginContext;
@@ -29,10 +28,8 @@ export function registerFileOperationRoutes(
app: Express,
{ ctx, sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
): void {
// NOTE: trash retention is an install-wide admin setting, not a per-host
// access check, so it stays on PermissionManager.isAdmin rather than
// ctx.hosts.checkAccess (see rule 5 scope: host resolution/access only).
const permissionManager = PermissionManager.getInstance();
// Trash retention is an install-wide admin setting, not a per-host check.
const canManageRetention = () => ctx.rbac.has("admin.settings.manage");
const getTrashRetentionDays = async (): Promise<number> => {
try {
const value = Number(await ctx.settings.get(TRASH_RETENTION_DAYS_KEY));
@@ -68,9 +65,7 @@ export function registerFileOperationRoutes(
res.json({
items: await listTrash(await getSessionSftp(session), retentionDays),
retentionDays,
canManageRetention: await permissionManager.isAdmin(
ctx.currentActor()!,
),
canManageRetention: await canManageRetention(),
});
} catch (error) {
ctx.log.error("Failed to list trash", error as Error);
@@ -121,8 +116,7 @@ export function registerFileOperationRoutes(
});
app.put("/trash-retention", async (req, res) => {
const userId = ctx.currentActor()!;
if (!(await permissionManager.isAdmin(userId))) {
if (!(await canManageRetention())) {
return res.status(403).json({ error: "Admin access required" });
}
const retentionDays = Number(req.body?.retentionDays);
+5 -1
View File
@@ -40,7 +40,11 @@ async function withServiceSftp<T>(
): Promise<T> {
return ctx.ssh.withConnection<T, import("ssh2").Client>(
hostId,
{ pool: "file-manager-service", purpose: "file-manager" },
{
pool: "file-manager-service",
purpose: "file-manager",
profile: "session",
},
(client) =>
new Promise<T>((resolve, reject) => {
client.sftp(async (err, sftp) => {
@@ -1,5 +1,5 @@
import { fileManagerHostSetting } from "./host-settings";
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "./lib/error-message";
/* eslint-disable react-hooks/exhaustive-deps */
import React, {
useState,
@@ -8,8 +8,8 @@ import React, {
useCallback,
useMemo,
} from "react";
import { asHttpError } from "@/lib/http-error";
import { cn } from "@/lib/utils.ts";
import { asHttpError } from "./lib/http-error";
import { cn } from "@termix/plugin-sdk/ui";
import { FileManagerGrid } from "./FileManagerGrid.tsx";
import { FileManagerSidebar, type SidebarItem } from "./FileManagerSidebar.tsx";
import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx";
@@ -24,33 +24,33 @@ import { DownloadProgressToast } from "./components/DownloadProgressToast.tsx";
import { DiffWindow } from "./components/DiffWindow.tsx";
import { useDragToDesktop } from "./hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "./hooks/useDragToSystemDesktop";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { useConfirmation } from "@termix/plugin-sdk/ui";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { FileManagerDialogs } from "./FileManagerDialogs.tsx";
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx";
import { PassphraseDialog } from "@termix/plugin-sdk/ui";
import { FileManagerToolbar } from "./FileManagerToolbar.tsx";
import { LocalFilePane } from "./LocalFilePane.tsx";
import { useLocalTransfers } from "./hooks/useLocalTransfers.ts";
import { usePaneWidth } from "./hooks/usePaneWidth.ts";
import { PaneResizeHandle } from "./components/PaneResizeHandle.tsx";
import { isLocalFileBrowserAvailable } from "@/lib/local-files.ts";
import { isLocalFileBrowserAvailable } from "./lib/local-files";
import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
import { FileManagerTrashDialog } from "./FileManagerTrashDialog.tsx";
import { TerminalWindow } from "./components/TerminalWindow.tsx";
import type { SSHHost, FileItem } from "@/types/index";
import type { SSHHost, FileItem } from "./host-types";
import {
ConnectionLogProvider,
useConnectionLog,
ConnectionScreen,
} from "@termix/plugin-sdk/ui";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import { ConnectionLogPanel } from "@termix/plugin-sdk/ui";
import {
invokeAction,
useConnectionRetry,
logActivity,
} from "@termix/plugin-sdk/frontend";
import { copyToClipboard } from "@/lib/clipboard.ts";
import { copyToClipboard } from "@termix/plugin-sdk/ui";
import {
listSSHFiles,
readSSHFile,
@@ -85,13 +85,13 @@ import {
transferToHost,
addTransferRecent,
type TransferMethodPreference,
} from "@/main-axios.ts";
} from "./api/transfer-api";
import type { DiskFilesystem, HostDiskInfo } from "./disk-info";
import {
getAdaptiveResourceBudget,
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
} from "@termix/plugin-sdk/ui";
import { shouldPrefetchFileContent } from "./lib/file-content-request-cache";
import {
markFilePreviewUsed,
@@ -1,5 +1,5 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import {
Download,
Edit3,
@@ -22,7 +22,7 @@ import {
Link,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui";
const VIEWPORT_PADDING = 16;
@@ -1,4 +1,4 @@
import type { FileItem, SSHHost } from "@/types/index";
import type { FileItem, SSHHost } from "./host-types";
import {
TOTPDialog,
SSHAuthDialog,
@@ -9,7 +9,7 @@ import React, {
} from "react";
import { createPortal } from "react-dom";
import { useVirtualizer } from "@tanstack/react-virtual";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import {
Folder,
File,
@@ -30,7 +30,7 @@ import {
GitCompare,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FileItem } from "@/types/index";
import type { FileItem } from "./host-types";
import type { CreateIntent } from "./file-manager-types.ts";
import { formatFileSize } from "./file-manager-utils.ts";
import {
@@ -5,7 +5,7 @@ import React, {
useCallback,
useMemo,
} from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import {
Star,
Clock,
@@ -17,7 +17,7 @@ import {
Check,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types";
import type { SSHHost } from "./host-types";
import {
getRecentFiles,
getPinnedFiles,
@@ -29,7 +29,7 @@ import {
import { listSSHFiles } from "./api/ssh-file-operations-api";
import type { DiskFilesystem } from "./disk-info";
import { toast } from "sonner";
import FolderTree from "@/components/folder.tsx";
import FolderTree from "./components/FolderTree";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -30,7 +30,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@termix/plugin-sdk/ui";
import type { FileItem } from "@/types/index";
import type { FileItem } from "./host-types";
type SortBy = "name" | "modified" | "size";
type SortOrder = "asc" | "desc";
@@ -1,5 +1,5 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import {
Clipboard,
Edit3,
@@ -14,8 +14,8 @@ import {
Upload,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
import type { LocalFileEntry } from "@/types/electron";
import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
const VIEWPORT_PADDING = 16;
@@ -26,9 +26,9 @@ import {
RefreshCw,
X,
} from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import { Button, Input } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@/types/electron";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
import {
createLocalFile,
createLocalFolder,
@@ -38,9 +38,9 @@ import {
renameLocalEntry,
revealLocalPath,
trashLocalPaths,
} from "@/lib/local-files.ts";
import { copyToClipboard } from "@/lib/clipboard.ts";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
} from "./lib/local-files";
import { copyToClipboard } from "@termix/plugin-sdk/ui";
import { useConfirmation } from "@termix/plugin-sdk/ui";
import { LocalFileContextMenu } from "./LocalFileContextMenu.tsx";
import {
useResizableColumns,
@@ -38,10 +38,10 @@ import {
} from "@termix/plugin-sdk/ui";
import {
addTransferRecent,
getSSHHosts,
transferToHost,
type TransferProgressResponse,
} from "@/main-axios";
} from "./api/transfer-api";
import { getSSHHosts } from "./api/hosts";
import {
browseSSHDirectory,
changeSSHPermissions,
@@ -51,7 +51,7 @@ import {
renameSSHItem,
type HostConnectionState,
} from "./api/ssh-file-operations-api";
import type { SSHHost } from "@/types";
import type { SSHHost } from "./host-types";
import { PermissionsDialog } from "./components/PermissionsDialog";
import { formatFileSize } from "./file-manager-utils";
import { beginTransferProgressMonitoring } from "./transferProgressMonitor";
@@ -581,7 +581,7 @@ export function SftpTransferTab() {
useEffect(() => {
setHostsLoading(true);
getSSHHosts({ includeStatus: false })
getSSHHosts()
.then((data) =>
setHosts(
data.filter(
@@ -1,6 +1,6 @@
import type { TranslateFn } from "@termix/plugin-sdk/frontend";
import type { TFunction } from "i18next";
import { getTransferStatus, listActiveTransfers } from "@/main-axios.ts";
import { getTransferStatus, listActiveTransfers } from "./api/transfer-api";
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
import {
beginTransferProgressMonitoring,
@@ -12,7 +12,7 @@ import {
getPendingTransferIds,
isTransferNotified,
} from "./transferNotificationStore.ts";
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
import { runAdaptivePolling } from "@termix/plugin-sdk/ui";
const POLL_INTERVAL_MS = 2000;
@@ -0,0 +1,121 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
// Response bodies are typed at each call site's return type.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Body = any;
interface ClientResponse<T> {
data: T;
status: number;
headers: Record<string, unknown>;
}
/** The plugin client is axios underneath; these are the parts used here. */
export interface FileManagerClient {
get<T = Body>(url: string, config?: unknown): Promise<ClientResponse<T>>;
delete<T = Body>(url: string, config?: unknown): Promise<ClientResponse<T>>;
post<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
put<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
patch<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
postForm<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
}
let current: TermixApp | null = null;
/** Set in activate, cleared on deactivate. */
export function setFileManagerApp(app: TermixApp | null): void {
current = app;
}
function app(): TermixApp {
if (!current) throw new Error("The file manager plugin is not active");
return current;
}
export function fileManagerApi(): FileManagerClient {
return app().api as unknown as FileManagerClient;
}
// A live SSH session (keyed by sessionId) remembers which backend holds it,
// so every later call for that session reaches the same server.
const sessionOrigins = new Map<string, "local" | "remote">();
export function setSessionOrigin(
sessionId: string,
origin: "local" | "remote",
): void {
sessionOrigins.set(sessionId, origin);
}
export function clearSessionOrigin(sessionId: string): void {
sessionOrigins.delete(sessionId);
}
export function getSessionOrigin(sessionId: string): "local" | "remote" {
return sessionOrigins.get(sessionId) === "remote" ? "remote" : "local";
}
export function getFileManagerApiForSession(
sessionId: string,
): FileManagerClient {
return app().apiFor(
getSessionOrigin(sessionId),
) as unknown as FileManagerClient;
}
export function fileManagerApiFor(
origin: "local" | "remote" | undefined,
): FileManagerClient {
return app().apiFor(origin ?? "local") as unknown as FileManagerClient;
}
class ApiError extends Error {
constructor(
message: string,
public status?: number,
public code?: string,
) {
super(message);
this.name = "ApiError";
}
}
interface AxiosLikeError {
message?: string;
response?: {
status?: number;
data?: { message?: string; error?: string; code?: string };
};
}
/** Turns an axios failure into an Error carrying the server's message. */
export function handleApiError(error: unknown, operation: string): never {
const e = error as AxiosLikeError;
if (e?.response) {
const status = e.response.status;
const data = e.response.data ?? {};
const message =
data.message || data.error || e.message || `Failed to ${operation}`;
const apiError = new ApiError(message, status, data.code || data.error);
(apiError as ApiError & { response?: unknown }).response = e.response;
throw apiError;
}
if (error instanceof Error) throw error;
throw new ApiError(`Failed to ${operation}`);
}
@@ -1,4 +1,4 @@
import { fileManagerApi, handleApiError } from "@/main-axios";
import { fileManagerApi, handleApiError } from "./client";
/** Row shape shared by the recent / pinned / shortcut list endpoints. */
export interface FileManagerEntry {
@@ -16,7 +16,7 @@ export async function getRecentFiles(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await fileManagerApi.get("/recent", {
const response = await fileManagerApi().get("/recent", {
params: { hostId },
});
return response.data;
@@ -32,7 +32,7 @@ export async function addRecentFile(
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/recent", {
const response = await fileManagerApi().post("/recent", {
hostId,
path,
name,
@@ -49,7 +49,7 @@ export async function removeRecentFile(
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.delete("/recent", {
const response = await fileManagerApi().delete("/recent", {
data: { hostId, path },
});
return response.data;
@@ -63,7 +63,7 @@ export async function getPinnedFiles(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await fileManagerApi.get("/pinned", {
const response = await fileManagerApi().get("/pinned", {
params: { hostId },
});
return response.data;
@@ -79,7 +79,7 @@ export async function addPinnedFile(
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/pinned", {
const response = await fileManagerApi().post("/pinned", {
hostId,
path,
name,
@@ -96,7 +96,7 @@ export async function removePinnedFile(
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.delete("/pinned", {
const response = await fileManagerApi().delete("/pinned", {
data: { hostId, path },
});
return response.data;
@@ -110,7 +110,7 @@ export async function getFolderShortcuts(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await fileManagerApi.get("/shortcuts", {
const response = await fileManagerApi().get("/shortcuts", {
params: { hostId },
});
return response.data;
@@ -126,7 +126,7 @@ export async function addFolderShortcut(
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/shortcuts", {
const response = await fileManagerApi().post("/shortcuts", {
hostId,
path,
name,
@@ -143,7 +143,7 @@ export async function removeFolderShortcut(
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.delete("/shortcuts", {
const response = await fileManagerApi().delete("/shortcuts", {
data: { hostId, path },
});
return response.data;
@@ -0,0 +1,6 @@
import { listHosts } from "@termix/plugin-sdk/frontend";
import type { SSHHost } from "../host-types";
export async function getSSHHosts(): Promise<SSHHost[]> {
return (await listHosts()) as unknown as SSHHost[];
}
@@ -12,9 +12,9 @@
import type {
LocalTransferOrigin,
LocalTransferProgress,
} from "@/types/electron";
import { getSessionOrigin } from "@/main-axios";
import { getDeviceId } from "@/lib/device-id";
} from "@termix/plugin-sdk/ui";
import { getSessionOrigin } from "./client";
import { getDeviceId } from "@termix/plugin-sdk/ui";
export interface LocalTransferProgressEvent {
transferred: number;
@@ -1,16 +1,17 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../lib/error-message";
import axios from "axios";
import { asHttpError } from "@/lib/http-error";
import { asHttpError } from "../lib/http-error";
import {
authApi,
handleApiError,
getFileManagerApiForSession,
setSessionOrigin,
clearSessionOrigin,
} from "@/main-axios";
import { resolveConnectionOrigin } from "@/lib/connection-origin";
import { fileLogger } from "@/lib/frontend-logger";
import type { FileItem, SSHHost } from "@/types/index";
} from "./client";
import {
createFrontendLogger,
resolveConnectionOrigin,
} from "@termix/plugin-sdk/ui";
import type { FileItem, SSHHost } from "../host-types";
import { getCachedFileList } from "../lib/file-list-request-cache";
import {
getCachedFileContent,
@@ -18,6 +19,8 @@ import {
type FileContentResult,
} from "../lib/file-content-request-cache";
const fileLogger = createFrontendLogger("FILE");
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
@@ -193,83 +196,6 @@ export async function verifySSHBrowserSignIn(
}
}
/**
* @openapi
* /ssh/quick-connect:
* post:
* summary: Create a temporary SSH connection without saving to database
* description: Returns a temporary host configuration for immediate use
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - ip
* - port
* - username
* - authType
* properties:
* ip:
* type: string
* description: SSH server IP or hostname
* port:
* type: number
* description: SSH server port
* username:
* type: string
* description: SSH username
* authType:
* type: string
* enum: [password, key, credential]
* description: Authentication method
* password:
* type: string
* description: Password (required if authType is password)
* key:
* type: string
* description: SSH private key (required if authType is key)
* keyPassword:
* type: string
* description: SSH key password (optional)
* keyType:
* type: string
* description: SSH key type
* credentialId:
* type: number
* description: Credential ID (required if authType is credential)
* overrideCredentialUsername:
* type: boolean
* description: Use provided username instead of credential username
* responses:
* 200:
* description: Temporary host configuration created successfully
* content:
* application/json:
* schema:
* type: object
* description: SSHHost object
* 400:
* description: Invalid request data
* 401:
* description: Unauthorized
* 500:
* description: Server error
*/
export async function quickConnect(
data: Record<string, unknown>,
): Promise<SSHHost> {
try {
const response = await authApi.post("/host/quick-connect", data);
return response.data;
} catch (error) {
throw handleApiError(error, "quick connect");
}
}
export async function getSSHStatus(
sessionId: string,
): Promise<{ connected: boolean }> {
@@ -0,0 +1,384 @@
import { createFrontendLogger } from "@termix/plugin-sdk/ui";
import { fileManagerApi, handleApiError } from "./client";
const fileLogger = createFrontendLogger("FILE");
// ============================================================================
// HOST-TO-HOST TRANSFER
// ============================================================================
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
export interface TransferScanSummary {
fileCount: number;
totalBytes: number;
largestFileBytes: number;
incompressibleRatio: number;
}
export interface TransferMethodPreview {
methodPreference: TransferMethodPreference;
resolvedMethod: "tar" | "item_sftp";
reasonKey: string;
sourcePlatform: "unix" | "windows";
destPlatform: "unix" | "windows";
sourceHasTar: boolean;
destHasTar: boolean;
summary: TransferScanSummary;
}
export async function getTransferMethodPreview(
sourceSessionId: string,
sourcePaths: string[],
destSessionId: string,
destPath: string,
methodPreference?: TransferMethodPreference,
): Promise<TransferMethodPreview> {
try {
const response = await fileManagerApi().post("/transferMethodPreview", {
sourceSessionId,
sourcePaths,
destSessionId,
destPath,
methodPreference: methodPreference ?? "auto",
});
return response.data;
} catch (error) {
handleApiError(error, "preview transfer method");
throw error;
}
}
export interface TransferHopMetrics {
id: string;
mbPerSec?: number;
}
export interface TransferTimings {
prepareDestMs?: number;
compressMs?: number;
transferMs?: number;
extractMs?: number;
verifyMs?: number;
directBenchmarkMs?: number;
relayBenchmarkMs?: number;
sourceDeleteMs?: number;
totalMs?: number;
transferBytes?: number;
endToEndMbPerSec?: number;
hops?: TransferHopMetrics[];
}
export function getTransferProgressPercent(
status: TransferProgressResponse,
): number | undefined {
if (
status.bytesTransferred !== undefined &&
status.totalBytes !== undefined &&
status.totalBytes > 0
) {
return Math.min(
100,
Math.round((status.bytesTransferred / status.totalBytes) * 100),
);
}
if (
status.itemsCompleted !== undefined &&
status.totalItems !== undefined &&
status.totalItems > 0
) {
return Math.min(
100,
Math.round((status.itemsCompleted / status.totalItems) * 100),
);
}
return undefined;
}
export function formatTransferMbPerSec(
mbPerSec?: number,
_bytes?: number,
_ms?: number,
): string {
if (mbPerSec === undefined || mbPerSec <= 0) return "";
if (mbPerSec < 1) return `${(mbPerSec * 1024).toFixed(0)} KB/s`;
return `${mbPerSec.toFixed(1)} MB/s`;
}
export function formatDurationMs(ms?: number): string {
if (ms === undefined) return "";
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60000);
const seconds = Math.round((ms % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
interface TransferProgressTracker {
update(status: TransferProgressResponse): {
rate: number | undefined;
stalled: boolean;
};
}
export function createTransferProgressTracker(): TransferProgressTracker {
let lastBytes: number | undefined;
let lastTime: number | undefined;
let lastRate: number | undefined;
let lastActivityTime: number | undefined;
const STALL_THRESHOLD_MS = 5000;
return {
update(status) {
const now = Date.now();
const bytes = status.bytesTransferred;
if (
bytes !== undefined &&
lastBytes !== undefined &&
lastTime !== undefined
) {
const deltaBytes = bytes - lastBytes;
const deltaMs = now - lastTime;
if (deltaMs > 0 && deltaBytes >= 0) {
lastRate = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
if (deltaBytes > 0) lastActivityTime = now;
}
} else if (bytes !== undefined) {
lastActivityTime = now;
}
lastBytes = bytes;
lastTime = now;
const stalled =
lastActivityTime !== undefined &&
now - lastActivityTime > STALL_THRESHOLD_MS &&
status.status === "running" &&
status.phase === "transferring";
return { rate: lastRate, stalled };
},
};
}
export interface TransferProgressResponse {
transferId: string;
status: "running" | "success" | "partial" | "error" | "cancelled";
phase:
| "compressing"
| "transferring"
| "benchmarking"
| "verifying"
| "extracting"
| "reconnecting";
bytesTransferred?: number;
totalBytes?: number;
itemsCompleted?: number;
totalItems?: number;
failedPaths?: string[];
message?: string;
method?: "stream" | "tar" | "item_sftp" | "direct_rsync";
sourcePaths?: string[];
destPath?: string;
sourceSessionId?: string;
destSessionId?: string;
startedAt?: number;
timings?: TransferTimings;
sourceDeleted?: boolean;
moveRequested?: boolean;
partialDestRemaining?: boolean;
cleanupCompleted?: boolean;
retryable?: boolean;
integrityVerified?: boolean;
parallelSegmentCount?: number;
}
export async function transferToHost(
sourceSessionId: string,
sourcePaths: string[],
destSessionId: string,
destPath: string,
move?: boolean,
methodPreference?: TransferMethodPreference,
parallelSegmentCount?: number,
): Promise<{ transferId: string }> {
try {
fileLogger.info("Starting host transfer", {
operation: "host_transfer",
sourceSessionId,
destSessionId,
sourcePaths,
destPath,
move,
methodPreference,
parallelSegmentCount,
});
const response = await fileManagerApi().post("/transferToHost", {
sourceSessionId,
sourcePaths,
destSessionId,
destPath,
move,
methodPreference: methodPreference ?? "auto",
parallelSegmentCount,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to start host transfer", error, {
operation: "host_transfer",
sourceSessionId,
destSessionId,
sourcePaths,
});
handleApiError(error, "transfer to host");
throw error;
}
}
export async function getTransferStatus(
transferId: string,
): Promise<TransferProgressResponse> {
try {
const response = await fileManagerApi().get(
`/transferStatus/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "get transfer status");
throw error;
}
}
export async function listActiveTransfers(): Promise<{
transfers: TransferProgressResponse[];
}> {
try {
const response = await fileManagerApi().get("/activeTransfers");
return response.data;
} catch (error) {
handleApiError(error, "list active transfers");
throw error;
}
}
export async function cancelTransferToHost(transferId: string): Promise<void> {
try {
await fileManagerApi().post(`/transferCancel/${transferId}`);
} catch (error) {
fileLogger.warn("Transfer cancel request failed (non-fatal)", {
operation: "host_transfer",
transferId,
error,
});
}
}
export async function cleanupCancelledTransfer(
transferId: string,
): Promise<{ removedPaths: string[]; failedPaths: string[] }> {
try {
const response = await fileManagerApi().post(
`/transferCleanup/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "clean up cancelled transfer");
throw error;
}
}
export async function retryTransferToHost(
transferId: string,
): Promise<{ ok: boolean; transferId: string }> {
try {
const response = await fileManagerApi().post(
`/transferRetry/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "retry transfer");
throw error;
}
}
export async function pollTransferUntilComplete(
transferId: string,
onProgress?: (status: TransferProgressResponse) => void,
intervalMs = 500,
): Promise<TransferProgressResponse> {
return new Promise((resolve, reject) => {
const poll = async () => {
try {
const status = await getTransferStatus(transferId);
onProgress?.(status);
if (
status.status === "success" ||
status.status === "partial" ||
status.status === "error" ||
status.status === "cancelled"
) {
resolve(status);
return;
}
setTimeout(poll, intervalMs);
} catch (err) {
reject(err);
}
};
void poll();
});
}
// ============================================================================
// FILE MANAGER DATA
// ============================================================================
export interface TransferDestination {
id: number;
userId: string;
sourceHostId: number;
destHostId: number;
destPath: string;
destPathLabel?: string;
lastUsed?: string;
}
export async function getTransferRecent(
sourceHostId: number,
): Promise<TransferDestination[]> {
try {
const response = await fileManagerApi().get("/transfer-recent", {
params: { sourceHostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get transfer recent destinations");
throw error;
}
}
export async function addTransferRecent(
sourceHostId: number,
destHostId: number,
destPath: string,
destPathLabel?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi().post("/transfer-recent", {
sourceHostId,
destHostId,
destPath,
destPathLabel,
});
return response.data;
} catch (error) {
handleApiError(error, "add transfer recent destination");
throw error;
}
}
@@ -2,7 +2,7 @@ import React from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import { Music } from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import { useTranslation } from "react-i18next";
interface FileItem {
@@ -1,5 +1,5 @@
import type React from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
interface ColumnResizeHandleProps {
onMouseDown: (event: React.MouseEvent) => void;
@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import type { ResizableColumns } from "../hooks/useResizableColumns.ts";
interface ColumnVisibilityMenuProps {
@@ -18,7 +18,7 @@ import {
getSSHStatus,
connectSSH,
} from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "@/types";
import type { FileItem, SSHHost } from "../host-types";
interface DiffViewerProps {
file1: FileItem;
@@ -3,7 +3,7 @@ import { DraggableWindow } from "./DraggableWindow.tsx";
import { DiffViewer } from "./DiffViewer.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
import type { FileItem, SSHHost } from "@/types/index";
import type { FileItem, SSHHost } from "../host-types";
interface DiffWindowProps {
windowId: string;
@@ -1,4 +1,4 @@
import { formatTransferMbPerSec } from "@/main-axios.ts";
import { formatTransferMbPerSec } from "../api/transfer-api";
import { useTranslation } from "react-i18next";
import { ArrowDownToLine } from "lucide-react";
import { TransferProgressBar } from "./TransferProgressBar";
@@ -1,5 +1,5 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -1,6 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { Suspense, lazy, useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import { useTranslation } from "react-i18next";
import {
FileText,
@@ -43,7 +43,7 @@ import {
SiDocker,
} from "react-icons/si";
import { Button } from "@termix/plugin-sdk/ui";
import { Kbd, KbdKey } from "@/components/kbd.tsx";
import { Kbd, KbdKey } from "@termix/plugin-sdk/ui";
import type { CodeEditorHandle } from "./CodeEditor.tsx";
import {
loadAudioPreview,
@@ -11,7 +11,7 @@ import {
connectSSH,
} from "../api/ssh-file-operations-api";
import { toast } from "sonner";
import type { SSHHost } from "@/types/index";
import type { SSHHost } from "../host-types";
import { useTranslation } from "react-i18next";
interface FileItem {
@@ -20,7 +20,7 @@ import {
File,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@termix/plugin-sdk/ui";
const animationVariants: Variants = {
rootInitial: { opacity: 0, y: 20 },
@@ -1,5 +1,5 @@
import type React from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
interface PaneResizeHandleProps {
label: string;
@@ -4,7 +4,7 @@ import type { TabHandle as TerminalHandle } from "@termix/plugin-sdk/frontend";
import { PluginComponent } from "@termix/plugin-sdk/ui";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types/index.ts";
import type { SSHHost } from "../host-types";
import { ExternalLink } from "lucide-react";
interface TerminalWindowProps {
@@ -1,6 +1,6 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@termix/plugin-sdk/ui";
export function TransferProgressBar({
value,
@@ -3,7 +3,7 @@ import {
formatTransferMbPerSec,
getTransferProgressPercent,
type TransferProgressResponse,
} from "@/main-axios.ts";
} from "../api/transfer-api";
import { useTranslation } from "react-i18next";
import {
Archive,
@@ -31,13 +31,13 @@ import {
Trash2,
} from "lucide-react";
import {
getSSHHosts,
getTransferRecent,
getTransferMethodPreview,
type TransferDestination,
type TransferMethodPreference,
type TransferMethodPreview,
} from "@/main-axios.ts";
} from "../api/transfer-api";
import { getSSHHosts } from "../api/hosts";
import {
getSSHStatus,
browseSSHDirectory,
@@ -49,7 +49,7 @@ import {
addFolderShortcut,
removeFolderShortcut,
} from "../api/file-manager-data-api";
import type { SSHHost } from "@/types";
import type { SSHHost } from "../host-types";
interface FileItem {
name: string;
@@ -1,5 +1,5 @@
import type { FileItem, SSHHost } from "@/types/index";
import type { LogEntry } from "@/types/connection-log.ts";
import type { FileItem, SSHHost } from "./host-types";
import type { LogEntry } from "@termix/plugin-sdk/ui";
export interface FileManagerProps {
initialHost?: SSHHost | null;
@@ -1,7 +1,7 @@
import {
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
} from "@termix/plugin-sdk/ui";
export type FilePreviewKind =
| "image"
@@ -6,7 +6,7 @@ import type {
FileManagerWidgetConfig,
WidgetComponentProps,
} from "./homepage.js";
import type { SSHHost } from "@/types/index";
import type { SSHHost } from "../host-types";
import { FileManager } from "../FileManager";
export function FileManagerWidget({
@@ -1,7 +1,7 @@
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { downloadSSHFile } from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "@/types/index.js";
import type { FileItem, SSHHost } from "../host-types";
interface DragToDesktopState {
isDragging: boolean;
@@ -2,7 +2,7 @@
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner";
import { downloadSSHFile } from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "@/types/index.js";
import type { FileItem, SSHHost } from "../host-types";
interface DragToSystemState {
isDragging: boolean;
@@ -2,8 +2,8 @@ import { useCallback, useRef } from "react";
import { createElement } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { FileItem } from "@/types/index";
import type { LocalWalkResult } from "@/types/electron";
import type { FileItem } from "../host-types";
import type { LocalWalkResult } from "@termix/plugin-sdk/ui";
import {
cancelLocalTransfer,
createLocalTransferId,
@@ -16,7 +16,7 @@ import {
getLocalHome,
localPathsExist,
walkLocalPaths,
} from "@/lib/local-files.ts";
} from "../lib/local-files";
import {
UnsafeLocalNameError,
buildLocalDestination,
@@ -0,0 +1,57 @@
/** A host as the shell hands it to the file manager. */
export interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
sudoPassword?: string;
forceKeyboardInteractive?: boolean;
credentialId?: number;
overrideCredentialUsername?: boolean;
userId?: string;
jumpHosts?: Array<{ hostId: number }>;
terminalConfig?: Record<string, unknown>;
notes?: string;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown[];
connectionType?: string;
enableSsh?: boolean;
syncId?: string | null;
createdAt: string;
updatedAt: string;
connectionOrigin?: "local" | "remote" | null;
instanceId?: string;
isShared?: boolean;
permissionLevel?: "connect" | "view" | "edit" | "manage";
/** Enabled plugins' host-scope settings, keyed by plugin id. */
pluginSettings?: Record<string, Record<string, unknown>>;
}
export interface FileItem {
name: string;
path: string;
isPinned?: boolean;
type: "file" | "directory" | "link";
sshSessionId?: string;
size?: number;
modified?: string;
modifiedTimestamp?: number;
permissions?: string;
owner?: string;
group?: string;
linkTarget?: string;
executable?: boolean;
}
+9 -4
View File
@@ -15,6 +15,7 @@ import { SftpTransferTab } from "./SftpTransferTab.tsx";
import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx";
import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx";
import { startTransferMonitor } from "./TransferMonitor.tsx";
import { setFileManagerApp } from "./api/client";
function FilesTab({ tab, host, sshHost, isVisible }: TabProps) {
const data = tab.data as
@@ -66,6 +67,8 @@ function openEditorAction(
}
export function activate(app: TermixApp): void {
setFileManagerApp(app);
app.onDispose(() => setFileManagerApp(null));
app.registerTab("files", FilesTab as unknown as ComponentType<TabProps>, {
icon: FolderSearch,
titleKey: "nav.files",
@@ -104,6 +107,7 @@ export function activate(app: TermixApp): void {
order: 20,
tabType: "files",
copyUrlView: "file-manager",
quickConnect: true,
when: (host) =>
!!host.enableSsh &&
fileManagerHostSetting(host, "enableFileManager", true),
@@ -122,10 +126,11 @@ export function activate(app: TermixApp): void {
editFormComponent: FileManagerWidgetEditForm as never,
});
app.registerAction("files.openHost", ((
host: PluginHostRecord | null,
path?: string,
) => openHostAction(app, host, path)) as never);
const openHost = ((host: PluginHostRecord | null, path?: string) =>
openHostAction(app, host, path)) as never;
app.registerAction("files.openHost", openHost);
// The shell's "open in file manager" tab button asks for this.
app.registerAction("host.openFiles", openHost);
app.registerAction("files.openEditor", ((
host: PluginHostRecord | null,
filePath: string,
@@ -0,0 +1,14 @@
/**
* Extract a stable, human-readable message from an unknown thrown value.
*
* A thrown value is not guaranteed to be an `Error` — libraries may throw
* strings or plain objects, which carry no useful `.message`. When that
* happens the caller gets `fallback` instead, so error paths never surface a
* raw `[object Object]` in logs or UI.
*/
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
@@ -1,5 +1,5 @@
import type { FileItem } from "@/types/index";
import { createKeyedRequestCache } from "@/lib/keyed-request-cache";
import type { FileItem } from "../host-types";
import { createKeyedRequestCache } from "./keyed-request-cache";
export interface FileContentResult {
content: string;
@@ -1,5 +1,5 @@
import type { FileItem } from "@/types/index";
import { createKeyedRequestCache } from "@/lib/keyed-request-cache";
import type { FileItem } from "../host-types";
import { createKeyedRequestCache } from "./keyed-request-cache";
export interface FileListResult {
files: FileItem[];
File renamed without changes.
@@ -8,8 +8,8 @@ import type {
LocalFsResult,
LocalTrashResult,
LocalWalkResult,
} from "@/types/electron";
import { isElectron } from "./electron";
} from "@termix/plugin-sdk/ui";
import { isElectron } from "@termix/plugin-sdk/ui";
export function isLocalFileBrowserAvailable(): boolean {
if (!isElectron()) return false;
@@ -1,7 +1,7 @@
// Pure helpers shared by the dual-pane (local <-> remote) file manager code.
// Kept free of React/DOM so they can be unit tested directly.
import type { LocalFileEntry } from "@/types/electron";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
/** Custom MIME type carried by drags that originate in the local pane. */
export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files";
@@ -1,4 +1,4 @@
import type { FileItem } from "@/types/index";
import type { FileItem } from "./host-types";
export function childPath(parent: string, name: string): string {
return parent === "/" ? `/${name}` : `${parent.replace(/\/$/, "")}/${name}`;
@@ -3,7 +3,7 @@ import {
formatDurationMs,
formatTransferMbPerSec,
type TransferTimings,
} from "@/main-axios.ts";
} from "./api/transfer-api";
export function createFormatTransferMetrics(t: TFunction) {
return (timings?: TransferTimings): string => {
@@ -1,4 +1,4 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "./lib/error-message";
import { toast } from "sonner";
import type { TFunction } from "i18next";
import {
@@ -9,7 +9,7 @@ import {
createTransferProgressTracker,
type TransferProgressResponse,
type TransferTimings,
} from "@/main-axios.ts";
} from "./api/transfer-api";
import { TransferProgressToast } from "./components/TransferProgressToast.tsx";
import {
markTransferNotified,
@@ -6,7 +6,7 @@ import {
LOCAL_FILES_DRAG_MIME,
REMOTE_FILES_DRAG_MIME,
} from "../../src/frontend/local-transfer-utils";
import type { LocalFileEntry } from "@/types/electron";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
// jsdom has no layout, so render every row instead of a virtual window.
vi.mock("@tanstack/react-virtual", () => ({
@@ -19,13 +19,16 @@ const api = vi.hoisted(() => ({
beginTransferProgressMonitoring: vi.fn(),
}));
vi.mock("@/main-axios", () => ({
vi.mock("../../src/frontend/api/transfer-api", () => ({
addTransferRecent: api.addTransferRecent,
getSSHHosts: api.getSSHHosts,
getTransferProgressPercent: api.getTransferProgressPercent,
transferToHost: api.transferToHost,
}));
vi.mock("../../src/frontend/api/hosts", () => ({
getSSHHosts: api.getSSHHosts,
}));
vi.mock("../../src/frontend/api/ssh-file-operations-api", () => ({
browseSSHDirectory: api.browseSSHDirectory,
changeSSHPermissions: api.changeSSHPermissions,
@@ -57,11 +57,12 @@ describe(`${manifest.id} activate`, () => {
expect(rendered.registered.hostEditorSections()).toEqual([]);
});
it("registers files.openHost and files.openEditor actions", async () => {
it("registers its open actions, including the shell's host.openFiles", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.actions().sort()).toEqual([
"files.openEditor",
"files.openHost",
"host.openFiles",
]);
});
@@ -4,9 +4,8 @@ const fileManagerApiMock = vi.hoisted(() => ({
post: vi.fn(async () => ({ data: { complete: false } })),
}));
vi.mock("@/main-axios", () => ({
authApi: { defaults: {} },
fileManagerApi: { defaults: {} },
vi.mock("../../../src/frontend/api/client", () => ({
fileManagerApi: () => ({ defaults: {} }),
getFileManagerApiForSession: () => fileManagerApiMock,
handleApiError: (error: unknown) => {
throw error;
@@ -14,19 +13,20 @@ vi.mock("@/main-axios", () => ({
setSessionOrigin: vi.fn(),
clearSessionOrigin: vi.fn(),
}));
vi.mock("@/lib/connection-origin", () => ({
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<object>()),
resolveConnectionOrigin: vi.fn(),
}));
vi.mock("@/lib/frontend-logger", () => ({
fileLogger: {
createFrontendLogger: () => ({
info: vi.fn(),
success: vi.fn(),
},
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("@/lib/file-list-request-cache", () => ({
vi.mock("../../../src/frontend/lib/file-list-request-cache", () => ({
getCachedFileList: vi.fn(),
}));
vi.mock("@/lib/file-content-request-cache", () => ({
vi.mock("../../../src/frontend/lib/file-content-request-cache", () => ({
getCachedFileContent: vi.fn(),
invalidateCachedFileContent: vi.fn(),
}));
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createKeyedRequestCache } from "../../lib/keyed-request-cache";
import { createKeyedRequestCache } from "../../../src/frontend/lib/keyed-request-cache";
afterEach(() => vi.useRealTimers());
@@ -26,7 +26,7 @@ import {
serializeLocalFilesDragPayload,
sortLocalEntries,
} from "../../src/frontend/local-transfer-utils";
import type { LocalFileEntry } from "@/types/electron";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
describe("drag payloads", () => {
it("round-trips local file payloads", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { FileItem } from "@/types/index";
import type { FileItem } from "../../src/frontend/host-types";
import {
addOptimisticItem,
childPath,
@@ -7,7 +7,7 @@ vi.mock("sonner", () => ({
},
}));
vi.mock("@/main-axios.ts", () => ({}));
vi.mock("../../src/frontend/api/transfer-api", () => ({}));
vi.mock("../../src/frontend/transferNotificationStore.ts", () => ({
markTransferNotified: vi.fn(),
}));
-6
View File
@@ -1,10 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
-6
View File
@@ -1,10 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
@@ -1,12 +0,0 @@
import React from "react";
import { HomepageCanvas } from "./HomepageCanvas";
const HomepageApp: React.FC = () => {
return (
<div className="h-screen w-screen overflow-hidden">
<HomepageCanvas fitOnLoad={true} />
</div>
);
};
export default HomepageApp;
-77
View File
@@ -1,77 +0,0 @@
import { useSyncExternalStore } from "react";
/**
* A keyed, reactive list of contributions.
*
* Widgets register on import, so anything rendered from a registry has to
* re-render when it changes. The snapshot array is cached until the next
* change because useSyncExternalStore compares by identity and loops forever
* on a fresh array.
*/
export interface Registry<T extends { id: string }> {
/** Adds or replaces by id. The disposer only removes this exact entry. */
register: (item: T) => () => void;
unregister: (id: string) => void;
get: (id: string) => T | undefined;
list: () => T[];
subscribe: (listener: () => void) => () => void;
/** React hook over list(). */
useList: () => T[];
reset: () => void;
}
export function createRegistry<T extends { id: string }>(
sort?: (a: T, b: T) => number,
): Registry<T> {
const items = new Map<string, T>();
const listeners = new Set<() => void>();
let snapshot: T[] | null = null;
const emit = () => {
snapshot = null;
for (const listener of listeners) listener();
};
const list = () => {
if (!snapshot) {
snapshot = [...items.values()];
if (sort) snapshot.sort(sort);
}
return snapshot;
};
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
return {
register(item) {
items.set(item.id, item);
emit();
return () => {
if (items.get(item.id) === item) {
items.delete(item.id);
emit();
}
};
},
unregister(id) {
if (items.delete(id)) emit();
},
get(id) {
return items.get(id);
},
list,
subscribe,
useList() {
return useSyncExternalStore(subscribe, list, list);
},
reset() {
items.clear();
emit();
},
};
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
Loaded 100 of 399 files, more files were not shown because too many files have changed in this diff. Show more