mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
1 parent
6489b62ece
commit
2961297989
399 files changed
+3964
-5627
No files matched your search
+24
-49
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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/`.
|
||||
@@ -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] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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?: (
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 () => [],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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,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"]
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -250,6 +250,7 @@ export async function startConnect(
|
||||
ctx.ssh
|
||||
.connect<Client>(host, {
|
||||
purpose: "docker",
|
||||
profile: "session",
|
||||
timeoutMs: CONNECT_TIMEOUT_MS,
|
||||
prompt: { ask },
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
@@ -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.
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
-1
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,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"]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user