chore: lock the plugin boundary and remove 2.9.0 leftovers

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

No files matched your search

+24 -49
View File
@@ -174,7 +174,7 @@ http {
root /app/html; root /app/html;
index index.html index.htm; index index.html index.htm;
expires off; 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 X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
@@ -271,24 +271,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 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(/.*)?$ { location ~ ^/sync(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -298,15 +280,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 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(/.*)?$ { location ~ ^/audit-logs(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -336,8 +309,29 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
# Automation webhooks lived at /automations/webhook/<token> before # 2.8 URLs that something outside Termix still calls: the Vault OIDC
# 2.9.0 and external systems still call that URL. # 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/ { location ^~ /automations/webhook/ {
rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last; rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last;
} }
@@ -455,25 +449,6 @@ http {
proxy_buffering off; 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(/.*)?$ { location ~ ^/encryption(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
+24 -49
View File
@@ -144,7 +144,7 @@ http {
root /app/html; root /app/html;
index index.html index.htm; index index.html index.htm;
expires off; 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 X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
@@ -240,24 +240,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 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(/.*)?$ { location ~ ^/sync(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -267,15 +249,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 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(/.*)?$ { location ~ ^/audit-logs(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -305,8 +278,29 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
# Automation webhooks lived at /automations/webhook/<token> before # 2.8 URLs that something outside Termix still calls: the Vault OIDC
# 2.9.0 and external systems still call that URL. # 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/ { location ^~ /automations/webhook/ {
rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last; rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last;
} }
@@ -424,25 +418,6 @@ http {
proxy_buffering off; 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(/.*)?$ { location ~ ^/encryption(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
+4 -3
View File
@@ -1,3 +1,4 @@
DROP TABLE `dashboard_service_links`;--> statement-breakpoint -- dashboard_service_links, homepage_items and homepage_layouts moved to the
DROP TABLE `homepage_items`;--> statement-breakpoint -- homepage plugin, which adopts them by rename at activation. Dropping them
DROP TABLE `homepage_layouts`; -- here would delete every row before the plugin's migration runs.
SELECT 1;
+7 -5
View File
@@ -317,16 +317,18 @@ function writeToRequest(request, chunk) {
} }
const TRANSFER_ROUTES = Object.freeze({ const TRANSFER_ROUTES = Object.freeze({
uploadFileStream: "/ssh/uploadFileStream", uploadFileStream: "/uploadFileStream",
downloadFileStream: "/ssh/downloadFileStream", downloadFileStream: "/downloadFileStream",
}); });
const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
// A compact JWT (base64url segments joined by dots); anything else is refused. // A compact JWT (base64url segments joined by dots); anything else is refused.
const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
const DEFAULT_LOCAL_FILE_MANAGER_BASE = // The file-manager plugin serves both routes under /plugin-api/file-manager
"http://localhost:30004/ssh/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) { function normalizeHttpBase(candidate, what) {
let parsed; let parsed;
@@ -408,7 +410,7 @@ function createTargetResolver({
const jwt = const jwt =
typeof getRemoteSyncJwt === "function" ? getRemoteSyncJwt() : null; typeof getRemoteSyncJwt === "function" ? getRemoteSyncJwt() : null;
if (jwt) headers.Authorization = `Bearer ${jwt}`; 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)}`); throw new Error(`Unknown transfer origin: ${String(origin)}`);
+1 -1
View File
@@ -1050,7 +1050,7 @@ function getBackendPidFilePath() {
// Manager) rather than through the normal quit flow, will-quit never fires // Manager) rather than through the normal quit flow, will-quit never fires
// and stopBackendServer() never runs -- the forked backend child is a // and stopBackendServer() never runs -- the forked backend child is a
// genuinely separate OS process on Windows/mac/Linux, so it keeps running // 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 // launch's own backend then fails outright
// with EADDRINUSE and the app is stuck until something manually kills the // with EADDRINUSE and the app is stuck until something manually kills the
// orphan. Reap any such leftover process, identified by PID file, before // orphan. Reap any such leftover process, identified by PID file, before
+51 -57
View File
@@ -5,6 +5,49 @@ import reactRefresh from "eslint-plugin-react-refresh";
import unusedImports from "eslint-plugin-unused-imports"; import unusedImports from "eslint-plugin-unused-imports";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import { globalIgnores } from "eslint/config"; 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([ export default tseslint.config([
globalIgnores([ globalIgnores([
@@ -108,17 +151,10 @@ export default tseslint.config([
// Core must not reach into a plugin. A plugin can be disabled, upgraded // 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 // or removed, so an import from core turns "disabled" into a broken
// build rather than a missing feature. Core talks to plugins through the // build rather than a missing feature. Core talks to plugins through the
// runtime in src/backend/plugins/ and the dispatchers in // runtime in src/backend/plugins/, which handles absence. Core tests only
// database/routes/*-dispatch.ts, both of which handle absence. // use fixture plugins.
//
// 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/.
files: ["src/**/*.{ts,tsx}"], files: ["src/**/*.{ts,tsx}"],
ignores: ["src/**/tests/**"],
rules: { rules: {
// An error for the backend, which has dispatchers to register through
// and no remaining offenders.
"no-restricted-imports": [ "no-restricted-imports": [
"error", "error",
{ {
@@ -126,7 +162,7 @@ export default tseslint.config([
{ {
group: ["**/plugins/*/src/backend/**"], group: ["**/plugins/*/src/backend/**"],
message: 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 // also carries the backend rule for src/ui files, because flat config
// replaces a rule's options rather than merging them. // replaces a rule's options rather than merging them.
files: ["src/ui/**/*.{ts,tsx}", "src/main.tsx"], files: ["src/ui/**/*.{ts,tsx}", "src/main.tsx"],
ignores: ["src/ui/tests/**"],
rules: { rules: {
"no-restricted-imports": [ "no-restricted-imports": [
"error", "error",
@@ -156,53 +191,12 @@ export default tseslint.config([
}, },
}, },
{ {
// The other direction. A plugin is supposed to talk to core only through // The other direction. A plugin reaches core only through
// @termix/plugin-sdk, which is the contract D1 finishes enforcing. // @termix/plugin-sdk, and never reaches into another plugin's source.
// files: ["plugins/*/src/**/*.{ts,tsx,mjs}", "plugins/*/tests/**/*.{ts,tsx}"],
// Only the backend is fenced today. Plugin backends reach core by plugins: { "termix-plugins": pluginBoundary },
// 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}"],
rules: { rules: {
"no-restricted-imports": [ "termix-plugins/stay-inside": "error",
"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.",
},
],
},
],
}, },
}, },
]); ]);
+120 -90
View File
@@ -1,93 +1,123 @@
{ {
"$schema": "https://unpkg.com/knip@latest/schema.json", "$schema": "https://unpkg.com/knip@latest/schema.json",
"entry": [ "workspaces": {
"src/backend/starter.ts", ".": {
"src/backend/swagger.ts", "entry": [
"scripts/**/*.cjs" "src/main.tsx",
], "src/backend/starter.ts",
"project": ["src/**/*.{ts,tsx}", "electron/**/*.cjs", "scripts/**/*.cjs"], "src/backend/utils/swagger.ts",
"ignoreBinaries": ["powershell"], "src/ui/plugin-host/sdk-ui.ts",
"ignoreDependencies": [ "src/ui/plugin-host/testing-host.tsx",
"@codemirror/autocomplete", "src/**/tests/**/*.{ts,tsx}",
"@codemirror/commands", "scripts/**/*.{cjs,mjs}",
"@codemirror/search", "electron/main.cjs",
"@codemirror/theme-one-dark", "electron/preload.js",
"@codemirror/view", "vitest.config.ts",
"@electron/notarize", "vitest.setup.ts",
"@monaco-editor/react", "vite.config.ts"
"@radix-ui/react-accordion", ],
"@radix-ui/react-alert-dialog", "project": [
"@radix-ui/react-checkbox", "src/**/*.{ts,tsx}",
"@radix-ui/react-dialog", "electron/**/*.cjs",
"@radix-ui/react-dropdown-menu", "scripts/**/*.{cjs,mjs}"
"@radix-ui/react-label", ],
"@radix-ui/react-popover", "ignoreBinaries": ["powershell"],
"@radix-ui/react-progress", "ignoreDependencies": [
"@radix-ui/react-scroll-area", "@codemirror/autocomplete",
"@radix-ui/react-select", "@codemirror/commands",
"@radix-ui/react-separator", "@codemirror/search",
"@radix-ui/react-slider", "@codemirror/theme-one-dark",
"@radix-ui/react-slot", "@codemirror/view",
"@radix-ui/react-switch", "@electron/notarize",
"@radix-ui/react-tabs", "@monaco-editor/react",
"@radix-ui/react-tooltip", "@radix-ui/react-accordion",
"@types/better-sqlite3", "@radix-ui/react-alert-dialog",
"@types/cookie-parser", "@radix-ui/react-checkbox",
"@types/cors", "@radix-ui/react-dialog",
"@types/express", "@radix-ui/react-dropdown-menu",
"@types/guacamole-common-js", "@radix-ui/react-label",
"@types/js-yaml", "@radix-ui/react-popover",
"@types/jsonwebtoken", "@radix-ui/react-progress",
"@types/multer", "@radix-ui/react-scroll-area",
"@types/ssh2", "@radix-ui/react-select",
"@uiw/codemirror-extensions-langs", "@radix-ui/react-separator",
"@uiw/codemirror-theme-github", "@radix-ui/react-slider",
"@uiw/react-codemirror", "@radix-ui/react-slot",
"@xterm/addon-clipboard", "@radix-ui/react-switch",
"@xterm/addon-fit", "@radix-ui/react-tabs",
"@xterm/addon-unicode11", "@radix-ui/react-tooltip",
"@xterm/addon-web-links", "@types/better-sqlite3",
"@xterm/xterm", "@types/cookie-parser",
"axios", "@types/cors",
"bcryptjs", "@types/express",
"better-sqlite3", "@types/guacamole-common-js",
"body-parser", "@types/js-yaml",
"chalk", "@types/jsonwebtoken",
"class-variance-authority", "@types/multer",
"clsx", "@types/ssh2",
"cmdk", "@uiw/codemirror-extensions-langs",
"cookie-parser", "@uiw/codemirror-theme-github",
"cors", "@uiw/react-codemirror",
"cytoscape", "@xterm/addon-clipboard",
"drizzle-orm", "@xterm/addon-fit",
"express", "@xterm/addon-unicode11",
"guacamole-lite", "@xterm/addon-web-links",
"guacamole-common-js", "@xterm/xterm",
"js-yaml", "axios",
"jsonwebtoken", "bcryptjs",
"jszip", "better-sqlite3",
"multer", "body-parser",
"nanoid", "chalk",
"i18next-browser-languagedetector", "class-variance-authority",
"lucide-react", "clsx",
"motion", "cmdk",
"radix-ui", "cookie-parser",
"react-cytoscapejs", "cors",
"react-h5-audio-player", "cytoscape",
"react-hook-form", "drizzle-orm",
"react-icons", "express",
"react-markdown", "guacamole-lite",
"react-pdf", "guacamole-common-js",
"react-photo-view", "js-yaml",
"react-syntax-highlighter", "jsonwebtoken",
"react-xtermjs", "jszip",
"remark-gfm", "multer",
"socks", "nanoid",
"ssh2", "i18next-browser-languagedetector",
"undici", "lucide-react",
"husky", "motion",
"sharp", "radix-ui",
"sonner", "react-cytoscapejs",
"tailwind-merge" "react-h5-audio-player",
] "react-hook-form",
"react-icons",
"react-markdown",
"react-pdf",
"react-photo-view",
"react-syntax-highlighter",
"react-xtermjs",
"remark-gfm",
"socks",
"ssh2",
"undici",
"husky",
"sharp",
"sonner",
"tailwind-merge"
]
},
"packages/plugin-sdk": {
"entry": ["src/*.ts", "src/testing/*.ts", "cli/**/*.mjs"],
"project": ["src/**/*.ts", "cli/**/*.mjs"]
},
"plugins/*": {
"entry": [
"src/backend/index.ts",
"src/frontend/index.{ts,tsx}",
"tests/**/*.{ts,tsx}",
"vitest.config.ts"
],
"project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"]
}
}
} }
+3 -3
View File
@@ -19,7 +19,7 @@
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-guacamole-common-js.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-xterm-android-ime.cjs && node scripts/patch-ssh2-agent.cjs", "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", "prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs", "prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && npm run plugin:validate-manifest && eslint . && node scripts/check-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 .", "lint:fix": "eslint --fix .",
"type-check": "tsc -b --force", "type-check": "tsc -b --force",
"test": "vitest run", "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": "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", "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: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": "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-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: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", "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", "preview": "vite preview",
"electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"", "electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"",
+194 -177
View File
@@ -43,8 +43,16 @@ dependencies. Entry points:
Plugins never import from `src/` or `@/`, and core never imports from Plugins never import from `src/` or `@/`, and core never imports from
`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK `plugins/`. Anything a plugin needs from core becomes a typed, documented SDK
API. Enforced by lint, with the exceptions recorded under API. Enforced by lint, with no exceptions:
[legacy core imports](#legacy-core-imports-the-debt-d1-removes).
- `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 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 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 `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 never writes the prefix, so it cannot claim a core group or another
plugin's namespace: `parseManifest` refuses a name starting with `hosts`, 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 re-checks against every namespace already in the catalog, because the validator
cannot know which other plugins exist. 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 artifacts in lockstep: the `schema.ts` declaration, the `index.ts` DDL, and the
regenerated `schema.pg.ts`/`schema.mysql.ts` plus drizzle migrations. 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` **Sync entities are registered, not hardcoded.** `ctx.sync.registerEntity`
adds an entity to remote sync; core registers its own the same way in 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 `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 `requestOrigin` and `isDataUnlocked()`, the data-key check a long-lived socket
repeats per message so an expired session stops being served. 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 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 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 `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 A plugin frontend is a built ESM bundle, `dist/frontend.js`, exporting
`activate(app)` and optionally `deactivate()`. The shell has no plugin ids and `activate(app)` and optionally `deactivate()`. The shell has no plugin ids and
no imports from `plugins/`; everything a plugin shows goes through the `app` 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 object. `scripts/check-core-plugin-ids.cjs` (run by lint) scans all of `src/`
plugin id in `src/ui` outside tests, apart from the entries in and fails on any plugin id core spells: a bare literal (`"docker"`), a plugin
`scripts/shell-plugin-id-allowlist.json`, each with a reason (host data like route (`"/plugin-api/docker/..."`), or an action, slot or permission id that
the `docker` runtime name, which Phase B moves). **B14** removed the `rdp`, starts with one (`"docker.open"`). The shell (`src/ui`) also may not spell a
`vnc` and `telnet` entries: the shell learns those protocols from view a plugin owns (a tab, panel or dashboard card id). There is no
`app.registerHostProtocol`. **B15** removed both `docker` entries: the Hosts allowlist. Three things are exempt, by rule rather than by entry:
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`. - 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 #### 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 #### Shared modules and the import map
A bundle keeps `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`, A bundle keeps `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`, `i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend` and
`@termix/plugin-sdk/ui` and `@termix/legacy-core/*` as bare imports. Core's `@termix/plugin-sdk/ui` as bare imports. Core's
Vite build (`scripts/vite-plugin-termix-plugins.mjs`) emits one entry per Vite build (`scripts/vite-plugin-termix-plugins.mjs`) emits one entry per
shared module, writes a stable unhashed shim for each at shared module, writes a stable unhashed shim for each at
`dist/shared/<name>.js`, and injects an import map into `index.html` mapping `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 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 `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 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 list of shared modules does.
import.
Electron loads `index.html` from `file://`. The map uses relative URLs, and Electron loads `index.html` from `file://`. The map uses relative URLs, and
plugin bundles come from the embedded backend 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 | | `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 | | `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**) | | `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 Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
name resolves to `<id>.<name>`), `useSettings`, `useHost`, `useHosts`, 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 own). The SDK has no runtime dependencies: the hooks delegate to a host
bridge core installs. 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** `terminal.sidePanel`, `terminal.overlay` (declared by ssh-terminal; **B9**
renamed `terminal.dock` to `terminal.sidePanel` and added the status slot, renamed `terminal.dock` to `terminal.sidePanel` and added the status slot,
where host-metrics puts its CPU, memory and disk bars; **C3** gave overlay 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 **B3**) is host-metrics's own: tailscale contributes its manager card there
instead of host-metrics knowing tailscale exists, and host-metrics works with instead of host-metrics knowing tailscale exists, and host-metrics works with
or without tailscale enabled. Cross-plugin frontend calls go through actions: or without tailscale enabled. Cross-plugin frontend calls go through actions:
`automations.list`, `fleets.list`, `session.remoteDisplay.token`. Core calls `automations.list`, `fleets.list`, `session.remoteDisplay.token`.
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.
**B16** added `host-metrics.disk` (the file manager's disk usage bar) and **B16** added `host-metrics.disk` (the file manager's disk usage bar) and
`snippets.get` / `snippets.execute` (host-metrics's quick actions); each `snippets.get` / `snippets.execute` (host-metrics's quick actions); each
answers undefined while its plugin is off. 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`, `@termix/plugin-sdk/frontend` functions: `logActivity`, `getHostPassword`,
`patchOpenTab`, `getCustomKeybindings`, `setHostAutoTmux` and `patchOpenTab`, `getCustomKeybindings`, `setHostAutoTmux` and
`getClientPreference`. `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 **B16** added `LineChart`, `useAdaptivePolling`, `useAreaPreferences` (the
Appearance density and chart options), and the homepage widget pieces Appearance density and chart options), and the homepage widget pieces
`WidgetTitle` and `runVisibleInterval` (moved to `src/ui/lib/` in **B19**, `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 #### Type checking
`tsconfig.plugins.frontend.json` checks every plugin frontend and its tests `npm run type-check` covers every plugin through two root configs:
with bundler resolution, and is part of `npm run type-check`. `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 ### 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 decryption, shared-host overrides and shared agent, and `op://` external
secret references. secret references.
2. `buildConnectConfig` builds the ssh2 config: the defaults for the 2. `buildConnectConfig` builds the ssh2 config: the defaults for the
connection's purpose (terminal, file-manager, tmux, metrics, fleet, docker, connection's profile (terminal, session, stream, forward, background or
...), the host key verifier, then the auth provider's `prepare`. jump), the host key verifier, then the auth provider's `prepare`.
3. `openSshTransport` does port knocking, the Cloudflare Access tunnel, the 3. `openSshTransport` does port knocking, the Cloudflare Access tunnel, the
jump host chain (every hop through steps 1 and 2) or SOCKS5. jump host chain (every hop through steps 1 and 2) or SOCKS5.
4. `connectHost` connects with a keyboard-interactive handler: a prompt channel 4. `connectHost` connects with a keyboard-interactive handler: a prompt channel
@@ -1867,48 +1976,51 @@ deciding, not the mechanism.
## The ctx surface ## The ctx surface
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. 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 | | Member | Capability | What it does |
| ------------------------------------------------ | -------------------------------------------------- | ----------------------------------- | | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** | | `pluginId`, `manifest` | none | The plugin's id and parsed manifest |
| `ctx.log.*` | none | **A1** | | `log.debug/info/warn/error` | none | Lines in core's log, tagged with the plugin |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** | | `events.emit/on` | `events:core` for core topics | Topics under `plugin.<id>.*`, or core's (`user.deleted`, `user.data_wiped`, host events) |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** | | `kv.get/set/delete/list` | `kv:own` | Small key/value state |
| `ctx.files.dataDir` | `files:own` | **B13** | | `files.dataDir()` | `files:own` | A per-plugin folder under `DATA_DIR` |
| `ctx.registry.*` | none | **A1** | | `db.define/client/refs/persist/dialect` | `db:own` (not `dialect`) | The plugin's own tables through Drizzle; `persist` flushes SQLite to disk |
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** | | `sync.registerEntity/recordTombstone` | none | Adds a table to remote sync, and records a delete for it |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** | | `registry.provide/consume/revoke` | none | The low-level named registry services sit on |
| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** | | `services.provide/get/providers` | per-service RBAC | Typed, versioned cross-plugin services, checked per call |
| `ctx.secrets.seal` / `.unseal` | `secrets:own` | **C1** | | `secrets.get/set/delete/seal/unseal` | `secrets:own` | The plugin's own encrypted values |
| `ctx.disposables.add` | none | **A1** | | `secrets.offer/withdraw/getShared` | per-secret RBAC | Secrets one plugin lends another |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** | | `http.router(options)`, `http.baseUrl(req)` | `network:serve` (router) | The router mounted at `/plugin-api/<id>/`, and the public origin for callback URLs |
| `ctx.currentActor()` | none | **A1** | | `ws.route/upgrade` | `network:serve` | Sockets at `/plugin-ws/<id>/<path>` |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** | | `rbac.has/hasFor/require` | own permissions (require) | The acting user's role permissions; `require` is route middleware |
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** | | `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 |
| `ctx.sync.registerEntity` | none | **A3** | | `settings.readCore(key)` | `settings:read-core` | One of the core keys in `CORE_SETTINGS_ALLOWLIST` |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** | | `disposables.add` | none | Extra cleanup on deactivate |
| `ctx.http.baseUrl(req)` | none | **C2** | | `capabilities.has/require` | the named capability | A generic check for privileged code no ctx member wraps |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** | | `hosts.list/get/checkAccess` | `hosts:read` | Hosts the acting user can see, without secrets |
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** | | `hosts.create/update/delete/listOwned/share/listUsers/listRoles` | `hosts:write` | Host changes and sharing on the user's behalf |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** | | `hosts.trackSession/recordActivity` | `hosts:read` | The online indicator and a recent activity entry |
| `ctx.hosts.status.*` | `hosts:read` | **B16** | | `hosts.status.get/check/reportLogin/registerPort` | `hosts:read` | Core's reachability status |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** | | `ssh.connect/withConnection/jumpChain` | `ssh:connect`, `credentials:use` | Connections through core's one connect pipeline |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | | `ssh.resolveHost/prepare/openTransport/classifyKeyboardInteractive/autoResponses/startInteraction/cancelInteraction` | `ssh:connect`, `credentials:use` | The lower level, for a transport with its own prompt flow |
| `ctx.notify.channels` / `.send` | `notify:send` | **B17** | | `ssh.poolKey/dropPooled/requiresSecret/supportsBackground` | `ssh:connect` | Pool housekeeping and questions for an auth type's provider |
| `ctx.auth.*` | `auth:provide` | **A8** | | `auth.registerLoginMethod/registerSecondFactor/recordEnrollment/removeEnrollment` | `auth:provide` | Login methods and second factors |
| `ctx.auth.completeRedirectLogin` and the rest | `auth:provide` | **C2** | | `auth.completeRedirectLogin/revokeSessions/loginRateLimit/countLinkedUsers` | `auth:provide` | What a login method needs from core's session and provisioning code |
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** | | `auth.registerSshAuthProvider/registerKeyboardInteractiveHandler` | `auth:provide` | SSH auth types and keyboard-interactive handlers |
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** | | `credentials.listSshKeys` / `.createSshKey` | `credentials:use` / `credentials:write` | The user's saved SSH keys |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** | | `credentials.resolveHostProtocol` | `credentials:read` | A host's RDP, VNC or Telnet login |
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** | | `credentials.registerSecretResolver` | `auth:provide` | Resolves external secret references in host and credential fields |
| `ctx.credentials.listSshKeys` | `credentials:use` | **C6** | | `desktop.openIsolatedWindow/launchNativeRdp/available` | `desktop:window` (not `available`) | Electron windows and the native RDP client |
| `ctx.credentials.createSshKey` | `credentials:write` | **C6** | | `audit.record` | none | An audit line under the plugin's own action name |
| `ctx.audit.record` | none, the actor is the runtime's | **B9**, request **D0** | | `schedule.every/after` | none | Timers that stop on deactivate and never overlap |
| `ctx.schedule.every` / `.after` | none | **B16** | | `notify.channels/send` | `notify:send` | Core's notification channels, as the acting user |
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** | | `fetch(url, init)` | `network:outbound` | Outbound HTTP through core's SSRF guard; `allowPrivateHosts` hosts also go through the configured proxy |
| `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** | | `process.run/ensureBinary` | `process:spawn` (+ `network:outbound` to download) | Programs on the server |
| `ctx.auth.registerKeyboardInteractiveHandler` | `auth:provide` | **C3** | | `system.tlsStatus/writeTlsCertificate/reloadTls/publishHttpChallenge/registerTlsRenewer` | `system:tls` | The server certificate |
| `ctx.system.*` | `system:tls` | **C7** | | `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 **B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
generic check for a capability no other ctx member wraps. Unlike every other 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. has no auth code of its own left.
- `requiresSecret(authType)` and `supportsBackground(authType)` ask the - `requiresSecret(authType)` and `supportsBackground(authType)` ask the
provider. provider.
- **B6** added `"file-manager"` and `"file-transfer"` to `PluginSshPurpose` - `purpose` is a free label (`"terminal"`, `"docker"`) that auth providers
(core's `SshConnectPurpose` already had them; the SDK type had lagged), see in `connectOptions`. **D1** moved the keepalive, timeout and
since the file-manager plugin's interactive connect route and its dedicated environment defaults onto `profile`: `"terminal"` (an interactive shell),
transfer sessions both need their own keepalive/timeout defaults rather than `"session"` (a long-lived browsing session such as the file manager or
falling back to the generic `"plugin"` purpose. Docker), `"stream"` (a long transfer or console), `"forward"` (port
- **B10** added `"tmux"` to `PluginSshPurpose` the same way (core's forwarding) or `"background"`, the default, for short exec work. Core's own
`SshConnectPurpose` already had it), for the tmux-monitor plugin's pooled jump chain uses a `"jump"` profile plugins cannot pick. Core no longer keeps
connection and the `tmux.sessions` service it provides to the terminal. a list of purposes, so it names no plugin.
- **B9** added what an interactive transport needs, for the terminal: - **B9** added what an interactive transport needs, for the terminal:
`resolveHost(hostId, { syncId? })` (the host with secrets, by sync id first, `resolveHost(hostId, { syncId? })` (the host with secrets, by sync id first,
audited), the `"terminal"` purpose, `interactive` and `hostKeySocket` on 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 what Docker and electron-builder package. `npm run build`, `build:backend` and
`dev:backend` all call it. `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. every emit. Nothing is written next to a source file.
### Host-provided packages ### Host-provided packages
@@ -2365,9 +2478,8 @@ and that is bundled into its output.
the terminal's image upload loads it on first use). the terminal's image upload loads it on first use).
**Frontend**: `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`, **Frontend**: `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`, `i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend` and
`@termix/plugin-sdk/ui` and `@termix/legacy-core/*`, all resolved through the `@termix/plugin-sdk/ui`, all resolved through the import map (see UI). Everything else, including `lucide-react`, `axios`,
import map (see UI). Everything else, including `lucide-react`, `axios`,
`cytoscape` and `guacamole-common-js`, is bundled into the plugin that uses `cytoscape` and `guacamole-common-js`, is bundled into the plugin that uses
it. it.
@@ -2673,10 +2785,10 @@ install (workspaces leaves out its `last_session` row).
2. Register everything in `activate(app)`: rail item (with `permission` when 2. Register everything in `activate(app)`: rail item (with `permission` when
there is one), panel, tabs, host editor sections, actions and slot there is one), panel, tabs, host editor sections, actions and slot
contributions. Use `app.onDispose` for timers. contributions. Use `app.onDispose` for timers.
3. Remove the shell's references: imports from the feature, hardcoded ids and 3. Remove core's references: imports from the feature, hardcoded ids and
switch cases, and the matching entries in switch cases. Where core still needs the feature, name a slot, action or
`scripts/shell-plugin-id-allowlist.json`. `node scripts/check-shell-plugin-ids.cjs` component id in core's own vocabulary and let the plugin fill it.
must pass. `node scripts/check-core-plugin-ids.cjs` must pass.
4. The rail item appears in Appearance > Sidebar > Navigation on its own 4. The rail item appears in Appearance > Sidebar > Navigation on its own
because it is registered; confirm it there. because it is registered; confirm it there.
5. No hardcoded text (a relative time is a translated string), no em dashes, 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 4. `cd plugins/<id> && npx vitest run` while working; `npm run test:plugins` at
the end. the end.
### 14. Boundary allowlist ### 14. Build, test and check by hand
`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
```bash ```bash
npm run build:sdk 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. 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. 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. 6. Update this document for anything the step added to the contract.
---
## Legacy core imports: the debt D1 removes
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
(B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2),
opkssh and warpgate (C3), step-ca (C4), vault (C5), termix-identity (C6),
acme-ssl (C7),
which import nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
and rewrites to the compiled output path (`../../../backend/backend/...`, or
`../../../backend/types/...` for shared types). The rewrite is output-relative,
so a source file at any nesting collapses to the same prefix. D1 deletes that
file.
Frontends do the same with `@/`: in the browser build, `@/x` and
`../src/ui/x` become the bare `@termix/legacy-core/ui/x` (or
`@termix/legacy-core/types/x`), which core's Vite build exposes as a shared
module so plugin and shell use one instance of each core module. The CLI
refuses the rewrite outside a Termix checkout. Every such import is counted
in the `plugin-frontend-to-core` direction below.
This is expected and temporary. The SDK does not yet expose what they need:
the SSH connection pool, host resolution, the repository layer, the shared
component library. D1 removes the debt once A3 through A8 have given them
supported APIs to move onto.
What the lint fence enforces today, in `eslint.config.mjs`:
| Direction | Severity | Offenders | Emptied by |
| ------------------------------------------------ | --------- | --------- | ---------- |
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 51 files | D1 |
| A plugin importing core by relative path | Warning | 3 files | D1 |
| A plugin importing another plugin's source | Warning | 0 files | - |
A warning does not fail a build, so the counts are held by
`scripts/check-plugin-boundaries.cjs`, run by `npm run lint`. Every offender is
listed in `scripts/plugin-boundary-allowlist.json`: a new one fails, and so
does an entry that stopped being an offender, so the list shrinks as each step
lands rather than rotting. `--write` regenerates it. D1 empties it and the two
plugin-side warnings become errors.
The two plugin-backend directions use different rule names
(`no-restricted-imports` and `@typescript-eslint/no-restricted-imports`)
because flat config replaces a rule's options rather than merging them, so one
rule cannot carry both severities on the same files.
Known specifics:
- **B17** removed the last plugin-to-plugin import: ai's proposal executor
and automation read tools call automations through the `automations.access`
service, and automations is an optional dependency of ai rather than a hard
one.
- **B16** moved host-metrics onto the SDK: it imports nothing from core or
automations. Automations reaches it through the `host-metrics.viewers`
service and its `plugin.host-metrics.*` events, and core reaches it through
the `dashboard.hostMetrics` and `homepage.hostMetrics` slots and the
`host-metrics.disk` action. Core's `hosts/metrics-shared/` is gone: ai,
automations, proxmox and tailscale use `@termix/plugin-sdk/host-commands`.
- Core feature servers that are not plugins yet still own ports: dashboard
30006 and homepage 30012. Each keeps its nginx block until its own Phase B
step. No plugin owns a port any more (A4). **B6** moved file-manager off
port 30004 onto `/plugin-api/file-manager/`, **B7** moved tunnels off port
30003 onto `/plugin-api/tunnels/` and `/plugin-ws/tunnels/c2s/stream`,
**B10** moved tmux monitoring off port 30010, and **B11** moved serial off
port 30011 onto `/plugin-ws/serial/console`.
- **B9** moved the terminal, its session manager, the local terminal and the
command history panel into ssh-terminal, which imports nothing from core.
The file manager's terminal window renders the `terminal.view` component
instead of importing the core terminal.
- Host data columns (`enableFileManager` and so on) stay in core types until
Phase B moves them. Only UI branches on them left the shell. **B15** moved
docker's `enableDocker` and `docker_config` (now `containerRuntime`) into
its host settings. **B14** moved
remote desktop's (`enableRdp`, `enableVnc`, `enableTelnet`, the three ports,
`rdpSecurity`, `rdpIgnoreCert`, `guacamoleConfig`) into its host settings;
the protocol logins stay core host fields.
- **B14** moved remote desktop onto the SDK: it imports nothing from core,
owns no module state, and its guacamole-lite server is built in `activate`
with its own signal handlers removed (guacamole-lite installs SIGTERM and
SIGINT handlers in its constructor). guacd is always an external service,
so it does not use `ctx.process`; its address is an admin setting.
+29
View File
@@ -154,3 +154,32 @@ with a real install, a real device or a repo outside this one.
copied in without a restart. copied in without a restart.
- A password user who signs in for the first time after the upgrade keeps - A password user who signs in for the first time after the upgrade keeps
their TOTP (the copy runs once their keys unlock). their TOTP (the copy runs once their keys unlock).
- D1 changes to check by hand: Quick Connect lists Tailscale next to the
core auth types and connects with it, and its "Connect to Files" button
comes from the file manager; the admin "manage user" panel's Snippets tab
(add, edit, delete a snippet for another user); the dashboard's active
tunnel counter and its click; the tab bar's file manager button; the
secret reference hint and manager in the host and credential editors;
Proxmox guest discovery and import (now its own `/plugin-api/proxmox/import`
route); the Tailscale device list behind an outbound proxy and against a
LAN Headscale; dragging a local file into a host in the desktop app (its
transfer URL moved off the dead port 30004); a per-user database export and
import that carries file manager bookmarks; the Simple preset hiding plugin
rail items except Snippets.
## Left for later
- 3.0.0: drop the migrated 2.8 settings rows (`guac_url`, `step_ca_url`,
`global_metrics_interval` and the rest). The upgrade moves leave them in
place so a downgrade to 2.8 still works; nothing reads them in 2.9.
- Plugin repo split: knip still reports about 150 unused exports inside
plugins (constants exported for readability, `tables.ts` exports the CLI
reads, test-only helpers). Prune them per plugin when it moves to its own
repo; core and the SDK are clean.
- Plugin OpenAPI docs: plugins carry `@openapi` comments but
`src/backend/utils/swagger.ts` only scans core routes, so
`npm run generate:openapi` leaves every `/plugin-api/` route out.
- Three backend tests sit next to their source, against the test layout
rule: `src/backend/hosts/host-session-status.test.ts`,
`jump-host-proxy.test.ts` and `ssh-keepalive.test.ts`. Move them under
`src/backend/tests/hosts/`.
+1 -21
View File
@@ -2,7 +2,6 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs"; import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
import { legacyCoreImports } from "../lib/legacy-core-imports.mjs";
import { staticUrlImports } from "../lib/static-url-imports.mjs"; import { staticUrlImports } from "../lib/static-url-imports.mjs";
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs"; import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
@@ -18,17 +17,6 @@ const FRONTEND_ENTRIES = [
"src/frontend/index.js", "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 }) { export async function build({ cwd }) {
const manifest = readManifest(cwd); const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd); const pluginId = manifest.id ?? path.basename(cwd);
@@ -59,7 +47,6 @@ export async function build({ cwd }) {
// node_modules import instead, resolved at runtime like a host-provided // node_modules import instead, resolved at runtime like a host-provided
// package. // package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])], external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
plugins: [legacyCoreImports({ pluginId, platform: "node" })],
}); });
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES); const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
@@ -75,14 +62,7 @@ export async function build({ cwd }) {
sourcemap: true, sourcemap: true,
logLevel: "warning", logLevel: "warning",
external: FRONTEND_EXTERNALS, external: FRONTEND_EXTERNALS,
plugins: [ plugins: [staticUrlImports({ outDir })],
staticUrlImports({ outDir }),
legacyCoreImports({
pluginId,
platform: "browser",
insideTermix: insideTermixCheckout(cwd),
}),
],
}); });
} }
@@ -42,6 +42,4 @@ export const FRONTEND_EXTERNALS = [
"sonner", "sonner",
"@termix/plugin-sdk/frontend", "@termix/plugin-sdk/frontend",
"@termix/plugin-sdk/ui", "@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", "type": "string",
"pattern": "^/[a-z][a-z0-9-]*(/[A-Za-z0-9_.-]+)+$" "pattern": "^/[a-z][a-z0-9-]*(/[A-Za-z0-9_.-]+)+$"
} }
},
"legacyRedirects": {
"type": "array",
"description": "Old URLs outside the plugin's namespace, redirected to a path in its router with the rest of the path and the query kept.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["from", "to"],
"properties": {
"from": {
"type": "string",
"pattern": "^(/[A-Za-z0-9_.-]+)+$"
},
"to": {
"type": "string",
"pattern": "^(/[A-Za-z0-9_.-]+)+$"
},
"status": { "enum": [307, 308] }
}
}
} }
} }
}, },
+20 -13
View File
@@ -737,19 +737,17 @@ export interface PluginSshHost {
} }
/** Picks keepalive and timeout defaults. "plugin" is the generic one. */ /** Picks keepalive and timeout defaults. "plugin" is the generic one. */
export type PluginSshPurpose = /** What a connection is for, as a free label. Auth providers see it. */
| "plugin" export type PluginSshPurpose = string;
| "docker"
| "docker-console" /**
| "metrics" * Keepalive, timeout and environment defaults for a kind of connection.
| "proxmox" * terminal: an interactive shell. session: a long-lived browsing session.
| "fleet" * stream: a long transfer or console. forward: port forwarding. background
| "remote-desktop" * (the default): short exec work such as polling.
| "tunnel" */
| "file-manager" export type PluginSshProfile =
| "file-transfer" "terminal" | "session" | "stream" | "forward" | "background";
| "terminal"
| "tmux";
export type PluginSshAuthOutcome = export type PluginSshAuthOutcome =
| { status: "ready" } | { status: "ready" }
@@ -868,6 +866,8 @@ export interface PluginSshPromptChannel {
export interface PluginSshConnectOptions { export interface PluginSshConnectOptions {
purpose?: PluginSshPurpose; purpose?: PluginSshPurpose;
/** Keepalive and timeout defaults. Defaults to "background". */
profile?: PluginSshProfile;
/** Overall time to reach "ready". Defaults to 30s. */ /** Overall time to reach "ready". Defaults to 30s. */
timeoutMs?: number; timeoutMs?: number;
/** Without one, the stored password answers password prompts. */ /** Without one, the stored password answers password prompts. */
@@ -956,6 +956,8 @@ export interface PluginSsh {
host: PluginSshHost, host: PluginSshHost,
options: { options: {
purpose?: PluginSshPurpose; purpose?: PluginSshPurpose;
/** Keepalive and timeout defaults. Defaults to "background". */
profile?: PluginSshProfile;
client: unknown; client: unknown;
/** Server-side host id when it differs from host.id. */ /** Server-side host id when it differs from host.id. */
serverHostId?: number; serverHostId?: number;
@@ -1079,6 +1081,11 @@ export interface PluginSshAuthProvider {
requiresSecret?: boolean; requiresSecret?: boolean;
/** Can connect unattended. Defaults to true. */ /** Can connect unattended. Defaults to true. */
supportsBackground?: boolean; 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 name its outcomes use, for startInteraction routing. */
interaction?: string; interaction?: string;
connectOptions?: ( connectOptions?: (
+32
View File
@@ -128,6 +128,11 @@ export interface RailItemContribution {
kind?: "panel" | "tab"; kind?: "panel" | "tab";
/** Users may hide it from Appearance > Sidebar > Navigation. Default true. */ /** Users may hide it from Appearance > Sidebar > Navigation. Default true. */
hideable?: boolean; 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. */ /** Can also open as a full-width tab. */
promotable?: boolean; promotable?: boolean;
/** Can open in the right dock. */ /** Can open in the right dock. */
@@ -302,6 +307,11 @@ export interface HostActionContribution {
copyUrlView?: string; copyUrlView?: string;
/** Where a host overview (the dashboard's host status list) sends a click. */ /** Where a host overview (the dashboard's host status list) sends a click. */
overview?: boolean; 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 * Position in the host row. Core: Files 20, Tunnel 40, Tmux 70. Connect
* actions default to 100 and sit after a separator. * actions default to 100 and sit after a separator.
@@ -709,6 +719,8 @@ export interface SshAuthTypeInfo {
credentialType: boolean; credentialType: boolean;
/** Can connect unattended, for polling. */ /** Can connect unattended, for polling. */
supportsBackground: boolean; 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>; setHostAutoTmux: (hostId: number, autoTmux: boolean) => Promise<void>;
/** A browser-side UI preference (a cookie, or the desktop app's store). */ /** A browser-side UI preference (a cookie, or the desktop app's store). */
getClientPreference: (name: string) => string | undefined; 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. */ /** 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); 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 { export function usePluginApi(): PluginApiClient {
const bridge = requireHost(); const bridge = requireHost();
return bridge.getApi(bridge.usePluginId()); return bridge.getApi(bridge.usePluginId());
+58 -2
View File
@@ -62,7 +62,6 @@ export type SystemRoleName = (typeof SYSTEM_ROLE_NAMES)[number];
*/ */
export const RESERVED_PERMISSION_PREFIXES = [ export const RESERVED_PERMISSION_PREFIXES = [
"hosts", "hosts",
"snippets",
"credentials", "credentials",
"admin", "admin",
] as const; ] as const;
@@ -324,6 +323,23 @@ export interface PluginHttpContribution {
* a clash. * a clash.
*/ */
legacyPaths?: string[]; 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 { 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 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( function validateHttpContribution(
value: unknown, value: unknown,
@@ -880,7 +930,13 @@ function validateHttpContribution(
errors.push('Field "contributes.http" must be an object'); errors.push('Field "contributes.http" must be an object');
return; return;
} }
rejectUnknown(value, ["legacyPaths"], "contributes.http", errors); rejectUnknown(
value,
["legacyPaths", "legacyRedirects"],
"contributes.http",
errors,
);
validateLegacyRedirects(value.legacyRedirects, errors);
const paths = value.legacyPaths; const paths = value.legacyPaths;
if (paths === undefined) return; if (paths === undefined) return;
if (!Array.isArray(paths)) { if (!Array.isArray(paths)) {
+3 -1
View File
@@ -14,7 +14,7 @@ import type {
PluginDisposables, PluginDisposables,
PluginLogger, PluginLogger,
} from "./backend.js"; } from "./backend.js";
import type { TermixApp } from "./frontend.js"; import type { HostActionContribution, TermixApp } from "./frontend.js";
import type { PluginManifest } from "./manifest.js"; import type { PluginManifest } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js"; import type { PluginTableDefinition } from "./db.js";
import type { import type {
@@ -1967,6 +1967,8 @@ export interface RenderedPluginApp {
tabs: () => string[]; tabs: () => string[];
panels: () => string[]; panels: () => string[];
hostActions: () => Array<{ id: string; tabType?: 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[]; hostProtocols: () => string[];
hostEditorSections: () => string[]; hostEditorSections: () => string[];
dashboardCards: () => string[]; dashboardCards: () => string[];
+3
View File
@@ -112,6 +112,7 @@ const offline = () =>
pluginId: "core", pluginId: "core",
credentialType: type === "password" || type === "key", credentialType: type === "password" || type === "key",
supportsBackground: type !== "none", supportsBackground: type !== "none",
quickConnect: true,
})), })),
loaded: true, loaded: true,
}), }),
@@ -138,6 +139,8 @@ const offline = () =>
getCustomKeybindings: async () => [], getCustomKeybindings: async () => [],
setHostAutoTmux: async () => {}, setHostAutoTmux: async () => {},
getClientPreference: () => undefined, getClientPreference: () => undefined,
listHosts: async () => [],
listCredentials: async () => [],
}, },
}; };
+2
View File
@@ -75,6 +75,8 @@ export function pluginVitestConfig(
"plugin-host", "plugin-host",
"testing-host.tsx", "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"), "@/types": path.join(repoRoot, "src", "types"),
"@": path.join(repoRoot, "src", "ui"), "@": path.join(repoRoot, "src", "ui"),
} }
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "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" "validate": "termix-plugin validate"
}, },
"dependencies": { "dependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "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" "validate": "termix-plugin validate"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "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" "validate": "termix-plugin validate"
}, },
"devDependencies": { "devDependencies": {
-6
View File
@@ -1,10 +1,4 @@
{ {
"extends": "@termix/plugin-sdk/tsconfig.plugin.json", "extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
} }
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "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" "validate": "termix-plugin validate"
}, },
"devDependencies": { "devDependencies": {
+1
View File
@@ -250,6 +250,7 @@ export async function startConnect(
ctx.ssh ctx.ssh
.connect<Client>(host, { .connect<Client>(host, {
purpose: "docker", purpose: "docker",
profile: "session",
timeoutMs: CONNECT_TIMEOUT_MS, timeoutMs: CONNECT_TIMEOUT_MS,
prompt: { ask }, prompt: { ask },
}) })
+1
View File
@@ -200,6 +200,7 @@ export function registerConsole(ctx: PluginContext, log: DockerLogger): void {
const { client, dispose } = await ctx.ssh.connect<Client>(host, { const { client, dispose } = await ctx.ssh.connect<Client>(host, {
purpose: "docker-console", purpose: "docker-console",
profile: "stream",
timeoutMs: 65_000, timeoutMs: 65_000,
}); });
if (ws.readyState !== WebSocket.OPEN) { if (ws.readyState !== WebSocket.OPEN) {
+2 -2
View File
@@ -92,7 +92,7 @@ export function registerServices(ctx: PluginContext, log: DockerLogger): void {
const { runtime } = await readDockerHostSettings(ctx, hostId); const { runtime } = await readDockerHostSettings(ctx, hostId);
const output = await ctx.ssh.withConnection<string, Client>( const output = await ctx.ssh.withConnection<string, Client>(
hostId, hostId,
{ pool: POOL, purpose: "docker" }, { pool: POOL, purpose: "docker", profile: "session" },
(client) => execOnClient(client, command(runtime)), (client) => execOnClient(client, command(runtime)),
); );
return handle(output); return handle(output);
@@ -161,7 +161,7 @@ export function registerServices(ctx: PluginContext, log: DockerLogger): void {
const { runtime } = await readDockerHostSettings(ctx, watch.hostId); const { runtime } = await readDockerHostSettings(ctx, watch.hostId);
const output = await ctx.ssh.withConnection<string, Client>( const output = await ctx.ssh.withConnection<string, Client>(
host, host,
{ pool: POOL, purpose: "docker" }, { pool: POOL, purpose: "docker", profile: "session" },
(client) => (client) =>
execOnClient( execOnClient(
client, client,
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "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" "validate": "termix-plugin validate"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,119 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export type ConnectionStage =
| "dns"
| "tcp"
| "handshake"
| "auth"
| "connected"
| "connection"
| "error"
| "proxy"
| "jump"
| "sftp_connecting"
| "sftp_auth"
| "sftp_connected";
export type LogEntry = {
id: string;
timestamp: Date;
type: "info" | "success" | "warning" | "error";
stage: ConnectionStage;
message: string;
details?: Record<string, unknown> | string;
};
export function createConnectionLog(
type: LogEntry["type"],
stage: ConnectionStage,
message: string,
details?: Record<string, unknown>,
): Omit<LogEntry, "id" | "timestamp"> {
return { type, stage, message, details };
}
function normalizeHostAddress(value: unknown): string {
if (typeof value !== "string") return "";
return value
.replace(/^\[|\]$/g, "")
.trim()
.toLowerCase();
}
/**
* Whether a numeric host id resolved to a different machine than the client
* meant. Ids from the desktop app and a sync server drift apart, and the
* resolved row supplies the credentials, so a mismatch must be refused.
*/
export function hostAddressMismatch(
clientAddress: unknown,
resolvedAddress: unknown,
): boolean {
const resolved = normalizeHostAddress(resolvedAddress);
if (!resolved) return false;
return resolved !== normalizeHostAddress(clientAddress);
}
export function resolveServerHostId(
clientHostId: number | null,
resolvedHost: { id?: unknown } | null,
): number | null {
return typeof resolvedHost?.id === "number" ? resolvedHost.id : clientHostId;
}
export class HostAddressMismatchError extends Error {
constructor() {
super(
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
"The host ids on this device and on the sync server have drifted apart. " +
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.',
);
this.name = "HostAddressMismatchError";
}
}
export class HostNotOnThisServerError extends Error {
constructor() {
super(
"This host does not exist on the sync server, so the connection was refused. " +
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.',
);
this.name = "HostNotOnThisServerError";
}
}
type Meta = Record<string, unknown>;
export interface FileLogger {
info: (message: string, meta?: Meta) => void;
success: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
error: (message: string, error?: unknown, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
if (!meta || Object.keys(meta).length === 0) return message;
try {
return `${message} ${JSON.stringify(meta)}`;
} catch {
return message;
}
}
/** ctx.log takes a plain message; this keeps the structured fields readable. */
export function createFileLogger(log: PluginLogger): FileLogger {
return {
info: (message, meta) => log.info(withMeta(message, meta)),
success: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, error, meta) => {
if (error instanceof Error) {
log.error(withMeta(message, meta), error);
} else if (error && typeof error === "object" && !meta) {
log.error(withMeta(message, error as Meta));
} else {
log.error(withMeta(message, meta));
}
},
};
}
+40 -60
View File
@@ -1,9 +1,5 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import express, { type Router } from "express"; import express, { type Router } from "express";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2"; import { Client as SSHClient } from "ssh2";
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend"; import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
import { getErrorMessage } from "./error-message.js"; import { getErrorMessage } from "./error-message.js";
@@ -15,30 +11,17 @@ import {
} from "./transfer-tuning.js"; } from "./transfer-tuning.js";
const TRANSFER_PROFILES_KEY = "transfer-profiles"; 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 { import {
createConnectionLog,
createFileLogger,
hostAddressMismatch, hostAddressMismatch,
HostAddressMismatchError, HostAddressMismatchError,
HostNotOnThisServerError, HostNotOnThisServerError,
resolveServerHostId, resolveServerHostId,
} from "../../../../src/backend/hosts/host-identity.js"; type ConnectionStage,
import type { type FileLogger,
LogEntry, type LogEntry,
ConnectionStage, } from "./host-identity.js";
} from "../../../../src/types/connection-log.js";
import { import {
ChannelOpenSerializer, ChannelOpenSerializer,
execChannel, execChannel,
@@ -66,7 +49,17 @@ import {
import { createFilesService } from "./service.js"; import { createFilesService } from "./service.js";
import { tables } from "./tables.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. */ /** Cosmetic only: whether the proxy step is worth a connection-log line. */
function hasSocks5Config(host: PluginSshHost): boolean { function hasSocks5Config(host: PluginSshHost): boolean {
@@ -115,6 +108,7 @@ function assertResolvedHost(
} }
export async function activate(ctx: PluginContext) { export async function activate(ctx: PluginContext) {
fileLogger = createFileLogger(ctx.log);
setPluginSsh(ctx.ssh); setPluginSsh(ctx.ssh);
setPluginCtx(ctx); setPluginCtx(ctx);
ctx.disposables.add(() => setPluginSsh(null)); ctx.disposables.add(() => setPluginSsh(null));
@@ -276,7 +270,7 @@ export async function activate(ctx: PluginContext) {
throw new Error("Cannot open transfer connection: unknown host"); 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) { if (!host) {
throw new Error("Host not found for transfer connection"); throw new Error("Host not found for transfer connection");
} }
@@ -304,6 +298,7 @@ export async function activate(ctx: PluginContext) {
host as unknown as PluginSshHost, host as unknown as PluginSshHost,
{ {
purpose: "file-transfer", purpose: "file-transfer",
profile: "stream",
timeoutMs: 60000, timeoutMs: 60000,
overrides: { tcpKeepAliveInitialDelay: 5000 }, overrides: { tcpKeepAliveInitialDelay: 5000 },
}, },
@@ -600,9 +595,9 @@ export async function activate(ctx: PluginContext) {
let resolvedSocks5ProxyChain = socks5ProxyChain; let resolvedSocks5ProxyChain = socks5ProxyChain;
const resolveFrom = async () => { const resolveFrom = async () => {
const resolvedHost = hostSyncId const resolvedHost = (await ctx.ssh.resolveHost(hostId, {
? await resolveHostBySyncId(hostSyncId, userId) syncId: hostSyncId,
: await resolveHostById(hostId, userId); })) as LooseHost | null;
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId); assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
if (!resolvedHost) return; if (!resolvedHost) return;
resolvedIp = resolvedHost.ip; resolvedIp = resolvedHost.ip;
@@ -690,7 +685,9 @@ export async function activate(ctx: PluginContext) {
let serverHostId = hostId; let serverHostId = hostId;
if (hostSyncId && userId) { if (hostSyncId && userId) {
const resolvedHost = await resolveHostBySyncId(hostSyncId, userId); const resolvedHost = await ctx.ssh.resolveHost(hostId, {
syncId: hostSyncId,
});
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId); assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
serverHostId = resolveServerHostId(hostId, resolvedHost) ?? hostId; serverHostId = resolveServerHostId(hostId, resolvedHost) ?? hostId;
} }
@@ -744,6 +741,7 @@ export async function activate(ctx: PluginContext) {
const ssh = pluginSsh(); const ssh = pluginSsh();
const prepared = await ssh.prepare(connectTarget, { const prepared = await ssh.prepare(connectTarget, {
purpose: "file-manager", purpose: "file-manager",
profile: "session",
client, client,
serverHostId, serverHostId,
log: (level, message) => log: (level, message) =>
@@ -850,17 +848,13 @@ export async function activate(ctx: PluginContext) {
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
if (userId) { if (userId) {
const { ipAddress, userAgent } = getRequestMeta(req);
void (async () => { void (async () => {
await logAudit({ await ctx.audit.record({
userId, request: req,
username: await getAuditUsername(userId),
action: "file_manager_connect", action: "file_manager_connect",
resourceType: "host", resourceType: "host",
resourceId: serverHostId ? String(serverHostId) : undefined, resourceId: serverHostId ? String(serverHostId) : undefined,
resourceName: `${username}@${ip}:${port}`, resourceName: `${username}@${ip}:${port}`,
ipAddress,
userAgent,
success: true, success: true,
}); });
})(); })();
@@ -1169,17 +1163,14 @@ export async function activate(ctx: PluginContext) {
hostId, hostId,
}); });
const stage = const stage =
error instanceof JumpHostChainError || jumpHostCount > 0 isJumpHostChainError(error) || jumpHostCount > 0 ? "jump" : "proxy";
? "jump"
: "proxy";
connectionLogs.push( connectionLogs.push(
createConnectionLog("error", stage, getErrorMessage(error)), createConnectionLog("error", stage, getErrorMessage(error)),
); );
return res.status(500).json({ return res.status(500).json({
error: error: isJumpHostChainError(error)
error instanceof JumpHostChainError ? `Failed to connect through jump hosts: ${error.message}`
? `Failed to connect through jump hosts: ${error.message}` : getErrorMessage(error),
: getErrorMessage(error),
connectionLogs, connectionLogs,
}); });
} }
@@ -1195,24 +1186,13 @@ export async function activate(ctx: PluginContext) {
port: number, port: number,
): Promise<void> { ): Promise<void> {
try { try {
const host = await createCurrentHostResolutionRepository().findHostById( const host = await ctx.hosts.get(hostId);
hostId, await ctx.asUser(userId, () =>
userId, ctx.hosts.recordActivity(
); hostId,
const hostName = "file_manager",
host?.userId === userId && host.name host?.name || `${username}@${ip}:${port}`,
? 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)}`,
},
},
); );
} catch (error) { } catch (error) {
fileLogger.warn("Failed to log file manager activity", { fileLogger.warn("Failed to log file manager activity", {
@@ -15,7 +15,6 @@ import {
permanentlyDeleteTrashItem, permanentlyDeleteTrashItem,
restoreTrashItem, restoreTrashItem,
} from "./trash-service.js"; } from "./trash-service.js";
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
type FileOperationRoutesDeps = { type FileOperationRoutesDeps = {
ctx: PluginContext; ctx: PluginContext;
@@ -29,10 +28,8 @@ export function registerFileOperationRoutes(
app: Express, app: Express,
{ ctx, sshSessions, verifySessionOwnership }: FileOperationRoutesDeps, { ctx, sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
): void { ): void {
// NOTE: trash retention is an install-wide admin setting, not a per-host // Trash retention is an install-wide admin setting, not a per-host check.
// access check, so it stays on PermissionManager.isAdmin rather than const canManageRetention = () => ctx.rbac.has("admin.settings.manage");
// ctx.hosts.checkAccess (see rule 5 scope: host resolution/access only).
const permissionManager = PermissionManager.getInstance();
const getTrashRetentionDays = async (): Promise<number> => { const getTrashRetentionDays = async (): Promise<number> => {
try { try {
const value = Number(await ctx.settings.get(TRASH_RETENTION_DAYS_KEY)); const value = Number(await ctx.settings.get(TRASH_RETENTION_DAYS_KEY));
@@ -68,9 +65,7 @@ export function registerFileOperationRoutes(
res.json({ res.json({
items: await listTrash(await getSessionSftp(session), retentionDays), items: await listTrash(await getSessionSftp(session), retentionDays),
retentionDays, retentionDays,
canManageRetention: await permissionManager.isAdmin( canManageRetention: await canManageRetention(),
ctx.currentActor()!,
),
}); });
} catch (error) { } catch (error) {
ctx.log.error("Failed to list trash", error as 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) => { app.put("/trash-retention", async (req, res) => {
const userId = ctx.currentActor()!; if (!(await canManageRetention())) {
if (!(await permissionManager.isAdmin(userId))) {
return res.status(403).json({ error: "Admin access required" }); return res.status(403).json({ error: "Admin access required" });
} }
const retentionDays = Number(req.body?.retentionDays); const retentionDays = Number(req.body?.retentionDays);
+5 -1
View File
@@ -40,7 +40,11 @@ async function withServiceSftp<T>(
): Promise<T> { ): Promise<T> {
return ctx.ssh.withConnection<T, import("ssh2").Client>( return ctx.ssh.withConnection<T, import("ssh2").Client>(
hostId, hostId,
{ pool: "file-manager-service", purpose: "file-manager" }, {
pool: "file-manager-service",
purpose: "file-manager",
profile: "session",
},
(client) => (client) =>
new Promise<T>((resolve, reject) => { new Promise<T>((resolve, reject) => {
client.sftp(async (err, sftp) => { client.sftp(async (err, sftp) => {
@@ -1,5 +1,5 @@
import { fileManagerHostSetting } from "./host-settings"; import { fileManagerHostSetting } from "./host-settings";
import { getErrorMessage } from "@/lib/error-message.js"; import { getErrorMessage } from "./lib/error-message";
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import React, { import React, {
useState, useState,
@@ -8,8 +8,8 @@ import React, {
useCallback, useCallback,
useMemo, useMemo,
} from "react"; } from "react";
import { asHttpError } from "@/lib/http-error"; import { asHttpError } from "./lib/http-error";
import { cn } from "@/lib/utils.ts"; import { cn } from "@termix/plugin-sdk/ui";
import { FileManagerGrid } from "./FileManagerGrid.tsx"; import { FileManagerGrid } from "./FileManagerGrid.tsx";
import { FileManagerSidebar, type SidebarItem } from "./FileManagerSidebar.tsx"; import { FileManagerSidebar, type SidebarItem } from "./FileManagerSidebar.tsx";
import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx"; import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx";
@@ -24,33 +24,33 @@ import { DownloadProgressToast } from "./components/DownloadProgressToast.tsx";
import { DiffWindow } from "./components/DiffWindow.tsx"; import { DiffWindow } from "./components/DiffWindow.tsx";
import { useDragToDesktop } from "./hooks/useDragToDesktop"; import { useDragToDesktop } from "./hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "./hooks/useDragToSystemDesktop"; import { useDragToSystemDesktop } from "./hooks/useDragToSystemDesktop";
import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { useConfirmation } from "@termix/plugin-sdk/ui";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FileManagerDialogs } from "./FileManagerDialogs.tsx"; 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 { FileManagerToolbar } from "./FileManagerToolbar.tsx";
import { LocalFilePane } from "./LocalFilePane.tsx"; import { LocalFilePane } from "./LocalFilePane.tsx";
import { useLocalTransfers } from "./hooks/useLocalTransfers.ts"; import { useLocalTransfers } from "./hooks/useLocalTransfers.ts";
import { usePaneWidth } from "./hooks/usePaneWidth.ts"; import { usePaneWidth } from "./hooks/usePaneWidth.ts";
import { PaneResizeHandle } from "./components/PaneResizeHandle.tsx"; 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 { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
import { FileManagerTrashDialog } from "./FileManagerTrashDialog.tsx"; import { FileManagerTrashDialog } from "./FileManagerTrashDialog.tsx";
import { TerminalWindow } from "./components/TerminalWindow.tsx"; import { TerminalWindow } from "./components/TerminalWindow.tsx";
import type { SSHHost, FileItem } from "@/types/index"; import type { SSHHost, FileItem } from "./host-types";
import { import {
ConnectionLogProvider, ConnectionLogProvider,
useConnectionLog, useConnectionLog,
ConnectionScreen, ConnectionScreen,
} from "@termix/plugin-sdk/ui"; } from "@termix/plugin-sdk/ui";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx"; import { ConnectionLogPanel } from "@termix/plugin-sdk/ui";
import { import {
invokeAction, invokeAction,
useConnectionRetry, useConnectionRetry,
logActivity, logActivity,
} from "@termix/plugin-sdk/frontend"; } from "@termix/plugin-sdk/frontend";
import { copyToClipboard } from "@/lib/clipboard.ts"; import { copyToClipboard } from "@termix/plugin-sdk/ui";
import { import {
listSSHFiles, listSSHFiles,
readSSHFile, readSSHFile,
@@ -85,13 +85,13 @@ import {
transferToHost, transferToHost,
addTransferRecent, addTransferRecent,
type TransferMethodPreference, type TransferMethodPreference,
} from "@/main-axios.ts"; } from "./api/transfer-api";
import type { DiskFilesystem, HostDiskInfo } from "./disk-info"; import type { DiskFilesystem, HostDiskInfo } from "./disk-info";
import { import {
getAdaptiveResourceBudget, getAdaptiveResourceBudget,
markAdaptiveResourceUsed, markAdaptiveResourceUsed,
runAdaptiveBackgroundTask, runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget"; } from "@termix/plugin-sdk/ui";
import { shouldPrefetchFileContent } from "./lib/file-content-request-cache"; import { shouldPrefetchFileContent } from "./lib/file-content-request-cache";
import { import {
markFilePreviewUsed, markFilePreviewUsed,
@@ -1,5 +1,5 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts"; import { cn } from "@termix/plugin-sdk/ui";
import { import {
Download, Download,
Edit3, Edit3,
@@ -22,7 +22,7 @@ import {
Link, Link,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; 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; const VIEWPORT_PADDING = 16;
@@ -1,4 +1,4 @@
import type { FileItem, SSHHost } from "@/types/index"; import type { FileItem, SSHHost } from "./host-types";
import { import {
TOTPDialog, TOTPDialog,
SSHAuthDialog, SSHAuthDialog,
@@ -9,7 +9,7 @@ import React, {
} from "react"; } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { cn } from "@/lib/utils.ts"; import { cn } from "@termix/plugin-sdk/ui";
import { import {
Folder, Folder,
File, File,
@@ -30,7 +30,7 @@ import {
GitCompare, GitCompare,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; 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 type { CreateIntent } from "./file-manager-types.ts";
import { formatFileSize } from "./file-manager-utils.ts"; import { formatFileSize } from "./file-manager-utils.ts";
import { import {
@@ -5,7 +5,7 @@ import React, {
useCallback, useCallback,
useMemo, useMemo,
} from "react"; } from "react";
import { cn } from "@/lib/utils.ts"; import { cn } from "@termix/plugin-sdk/ui";
import { import {
Star, Star,
Clock, Clock,
@@ -17,7 +17,7 @@ import {
Check, Check,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types"; import type { SSHHost } from "./host-types";
import { import {
getRecentFiles, getRecentFiles,
getPinnedFiles, getPinnedFiles,
@@ -29,7 +29,7 @@ import {
import { listSSHFiles } from "./api/ssh-file-operations-api"; import { listSSHFiles } from "./api/ssh-file-operations-api";
import type { DiskFilesystem } from "./disk-info"; import type { DiskFilesystem } from "./disk-info";
import { toast } from "sonner"; import { toast } from "sonner";
import FolderTree from "@/components/folder.tsx"; import FolderTree from "./components/FolderTree";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@@ -30,7 +30,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@termix/plugin-sdk/ui"; } from "@termix/plugin-sdk/ui";
import type { FileItem } from "@/types/index"; import type { FileItem } from "./host-types";
type SortBy = "name" | "modified" | "size"; type SortBy = "name" | "modified" | "size";
type SortOrder = "asc" | "desc"; type SortOrder = "asc" | "desc";
@@ -1,5 +1,5 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts"; import { cn } from "@termix/plugin-sdk/ui";
import { import {
Clipboard, Clipboard,
Edit3, Edit3,
@@ -14,8 +14,8 @@ import {
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx"; import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@/types/electron"; import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
const VIEWPORT_PADDING = 16; const VIEWPORT_PADDING = 16;
@@ -26,9 +26,9 @@ import {
RefreshCw, RefreshCw,
X, X,
} from "lucide-react"; } 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 { Button, Input } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@/types/electron"; import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
import { import {
createLocalFile, createLocalFile,
createLocalFolder, createLocalFolder,
@@ -38,9 +38,9 @@ import {
renameLocalEntry, renameLocalEntry,
revealLocalPath, revealLocalPath,
trashLocalPaths, trashLocalPaths,
} from "@/lib/local-files.ts"; } from "./lib/local-files";
import { copyToClipboard } from "@/lib/clipboard.ts"; import { copyToClipboard } from "@termix/plugin-sdk/ui";
import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { useConfirmation } from "@termix/plugin-sdk/ui";
import { LocalFileContextMenu } from "./LocalFileContextMenu.tsx"; import { LocalFileContextMenu } from "./LocalFileContextMenu.tsx";
import { import {
useResizableColumns, useResizableColumns,
@@ -38,10 +38,10 @@ import {
} from "@termix/plugin-sdk/ui"; } from "@termix/plugin-sdk/ui";
import { import {
addTransferRecent, addTransferRecent,
getSSHHosts,
transferToHost, transferToHost,
type TransferProgressResponse, type TransferProgressResponse,
} from "@/main-axios"; } from "./api/transfer-api";
import { getSSHHosts } from "./api/hosts";
import { import {
browseSSHDirectory, browseSSHDirectory,
changeSSHPermissions, changeSSHPermissions,
@@ -51,7 +51,7 @@ import {
renameSSHItem, renameSSHItem,
type HostConnectionState, type HostConnectionState,
} from "./api/ssh-file-operations-api"; } from "./api/ssh-file-operations-api";
import type { SSHHost } from "@/types"; import type { SSHHost } from "./host-types";
import { PermissionsDialog } from "./components/PermissionsDialog"; import { PermissionsDialog } from "./components/PermissionsDialog";
import { formatFileSize } from "./file-manager-utils"; import { formatFileSize } from "./file-manager-utils";
import { beginTransferProgressMonitoring } from "./transferProgressMonitor"; import { beginTransferProgressMonitoring } from "./transferProgressMonitor";
@@ -581,7 +581,7 @@ export function SftpTransferTab() {
useEffect(() => { useEffect(() => {
setHostsLoading(true); setHostsLoading(true);
getSSHHosts({ includeStatus: false }) getSSHHosts()
.then((data) => .then((data) =>
setHosts( setHosts(
data.filter( data.filter(
@@ -1,6 +1,6 @@
import type { TranslateFn } from "@termix/plugin-sdk/frontend"; import type { TranslateFn } from "@termix/plugin-sdk/frontend";
import type { TFunction } from "i18next"; 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 { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
import { import {
beginTransferProgressMonitoring, beginTransferProgressMonitoring,
@@ -12,7 +12,7 @@ import {
getPendingTransferIds, getPendingTransferIds,
isTransferNotified, isTransferNotified,
} from "./transferNotificationStore.ts"; } from "./transferNotificationStore.ts";
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts"; import { runAdaptivePolling } from "@termix/plugin-sdk/ui";
const POLL_INTERVAL_MS = 2000; 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. */ /** Row shape shared by the recent / pinned / shortcut list endpoints. */
export interface FileManagerEntry { export interface FileManagerEntry {
@@ -16,7 +16,7 @@ export async function getRecentFiles(
hostId: number, hostId: number,
): Promise<FileManagerEntry[]> { ): Promise<FileManagerEntry[]> {
try { try {
const response = await fileManagerApi.get("/recent", { const response = await fileManagerApi().get("/recent", {
params: { hostId }, params: { hostId },
}); });
return response.data; return response.data;
@@ -32,7 +32,7 @@ export async function addRecentFile(
name?: string, name?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post("/recent", { const response = await fileManagerApi().post("/recent", {
hostId, hostId,
path, path,
name, name,
@@ -49,7 +49,7 @@ export async function removeRecentFile(
path: string, path: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.delete("/recent", { const response = await fileManagerApi().delete("/recent", {
data: { hostId, path }, data: { hostId, path },
}); });
return response.data; return response.data;
@@ -63,7 +63,7 @@ export async function getPinnedFiles(
hostId: number, hostId: number,
): Promise<FileManagerEntry[]> { ): Promise<FileManagerEntry[]> {
try { try {
const response = await fileManagerApi.get("/pinned", { const response = await fileManagerApi().get("/pinned", {
params: { hostId }, params: { hostId },
}); });
return response.data; return response.data;
@@ -79,7 +79,7 @@ export async function addPinnedFile(
name?: string, name?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post("/pinned", { const response = await fileManagerApi().post("/pinned", {
hostId, hostId,
path, path,
name, name,
@@ -96,7 +96,7 @@ export async function removePinnedFile(
path: string, path: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.delete("/pinned", { const response = await fileManagerApi().delete("/pinned", {
data: { hostId, path }, data: { hostId, path },
}); });
return response.data; return response.data;
@@ -110,7 +110,7 @@ export async function getFolderShortcuts(
hostId: number, hostId: number,
): Promise<FileManagerEntry[]> { ): Promise<FileManagerEntry[]> {
try { try {
const response = await fileManagerApi.get("/shortcuts", { const response = await fileManagerApi().get("/shortcuts", {
params: { hostId }, params: { hostId },
}); });
return response.data; return response.data;
@@ -126,7 +126,7 @@ export async function addFolderShortcut(
name?: string, name?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post("/shortcuts", { const response = await fileManagerApi().post("/shortcuts", {
hostId, hostId,
path, path,
name, name,
@@ -143,7 +143,7 @@ export async function removeFolderShortcut(
path: string, path: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.delete("/shortcuts", { const response = await fileManagerApi().delete("/shortcuts", {
data: { hostId, path }, data: { hostId, path },
}); });
return response.data; 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 { import type {
LocalTransferOrigin, LocalTransferOrigin,
LocalTransferProgress, LocalTransferProgress,
} from "@/types/electron"; } from "@termix/plugin-sdk/ui";
import { getSessionOrigin } from "@/main-axios"; import { getSessionOrigin } from "./client";
import { getDeviceId } from "@/lib/device-id"; import { getDeviceId } from "@termix/plugin-sdk/ui";
export interface LocalTransferProgressEvent { export interface LocalTransferProgressEvent {
transferred: number; transferred: number;
@@ -1,16 +1,17 @@
import { getErrorMessage } from "@/lib/error-message.js"; import { getErrorMessage } from "../lib/error-message";
import axios from "axios"; import axios from "axios";
import { asHttpError } from "@/lib/http-error"; import { asHttpError } from "../lib/http-error";
import { import {
authApi,
handleApiError, handleApiError,
getFileManagerApiForSession, getFileManagerApiForSession,
setSessionOrigin, setSessionOrigin,
clearSessionOrigin, clearSessionOrigin,
} from "@/main-axios"; } from "./client";
import { resolveConnectionOrigin } from "@/lib/connection-origin"; import {
import { fileLogger } from "@/lib/frontend-logger"; createFrontendLogger,
import type { FileItem, SSHHost } from "@/types/index"; resolveConnectionOrigin,
} from "@termix/plugin-sdk/ui";
import type { FileItem, SSHHost } from "../host-types";
import { getCachedFileList } from "../lib/file-list-request-cache"; import { getCachedFileList } from "../lib/file-list-request-cache";
import { import {
getCachedFileContent, getCachedFileContent,
@@ -18,6 +19,8 @@ import {
type FileContentResult, type FileContentResult,
} from "../lib/file-content-request-cache"; } from "../lib/file-content-request-cache";
const fileLogger = createFrontendLogger("FILE");
type ApiConnectionLog = { type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error"; type: "info" | "success" | "warning" | "error";
stage: string; 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( export async function getSSHStatus(
sessionId: string, sessionId: string,
): Promise<{ connected: boolean }> { ): 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 AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css"; import "react-h5-audio-player/lib/styles.css";
import { Music } from "lucide-react"; import { Music } from "lucide-react";
import { cn } from "@/lib/utils.ts"; import { cn } from "@termix/plugin-sdk/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface FileItem { interface FileItem {
@@ -1,5 +1,5 @@
import type React from "react"; import type React from "react";
import { cn } from "@/lib/utils.ts"; import { cn } from "@termix/plugin-sdk/ui";
interface ColumnResizeHandleProps { interface ColumnResizeHandleProps {
onMouseDown: (event: React.MouseEvent) => void; onMouseDown: (event: React.MouseEvent) => void;
@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { useTranslation } from "react-i18next"; 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"; import type { ResizableColumns } from "../hooks/useResizableColumns.ts";
interface ColumnVisibilityMenuProps { interface ColumnVisibilityMenuProps {
@@ -18,7 +18,7 @@ import {
getSSHStatus, getSSHStatus,
connectSSH, connectSSH,
} from "../api/ssh-file-operations-api"; } from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "@/types"; import type { FileItem, SSHHost } from "../host-types";
interface DiffViewerProps { interface DiffViewerProps {
file1: FileItem; file1: FileItem;
@@ -3,7 +3,7 @@ import { DraggableWindow } from "./DraggableWindow.tsx";
import { DiffViewer } from "./DiffViewer.tsx"; import { DiffViewer } from "./DiffViewer.tsx";
import { useWindowManager } from "./WindowManager.tsx"; import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { FileItem, SSHHost } from "@/types/index"; import type { FileItem, SSHHost } from "../host-types";
interface DiffWindowProps { interface DiffWindowProps {
windowId: string; windowId: string;
@@ -1,4 +1,4 @@
import { formatTransferMbPerSec } from "@/main-axios.ts"; import { formatTransferMbPerSec } from "../api/transfer-api";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowDownToLine } from "lucide-react"; import { ArrowDownToLine } from "lucide-react";
import { TransferProgressBar } from "./TransferProgressBar"; import { TransferProgressBar } from "./TransferProgressBar";
@@ -1,5 +1,5 @@
import React, { useState, useRef, useCallback, useEffect } from "react"; 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 { Minus, X, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -1,6 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import React, { Suspense, lazy, useState, useEffect, useRef } from "react"; 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 { useTranslation } from "react-i18next";
import { import {
FileText, FileText,
@@ -43,7 +43,7 @@ import {
SiDocker, SiDocker,
} from "react-icons/si"; } from "react-icons/si";
import { Button } from "@termix/plugin-sdk/ui"; 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 type { CodeEditorHandle } from "./CodeEditor.tsx";
import { import {
loadAudioPreview, loadAudioPreview,
@@ -11,7 +11,7 @@ import {
connectSSH, connectSSH,
} from "../api/ssh-file-operations-api"; } from "../api/ssh-file-operations-api";
import { toast } from "sonner"; import { toast } from "sonner";
import type { SSHHost } from "@/types/index"; import type { SSHHost } from "../host-types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface FileItem { interface FileItem {
@@ -20,7 +20,7 @@ import {
File, File,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@termix/plugin-sdk/ui";
const animationVariants: Variants = { const animationVariants: Variants = {
rootInitial: { opacity: 0, y: 20 }, rootInitial: { opacity: 0, y: 20 },
@@ -1,5 +1,5 @@
import type React from "react"; import type React from "react";
import { cn } from "@/lib/utils.ts"; import { cn } from "@termix/plugin-sdk/ui";
interface PaneResizeHandleProps { interface PaneResizeHandleProps {
label: string; label: string;
@@ -4,7 +4,7 @@ import type { TabHandle as TerminalHandle } from "@termix/plugin-sdk/frontend";
import { PluginComponent } from "@termix/plugin-sdk/ui"; import { PluginComponent } from "@termix/plugin-sdk/ui";
import { useWindowManager } from "./WindowManager.tsx"; import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types/index.ts"; import type { SSHHost } from "../host-types";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
interface TerminalWindowProps { interface TerminalWindowProps {
@@ -1,6 +1,6 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@termix/plugin-sdk/ui";
export function TransferProgressBar({ export function TransferProgressBar({
value, value,
@@ -3,7 +3,7 @@ import {
formatTransferMbPerSec, formatTransferMbPerSec,
getTransferProgressPercent, getTransferProgressPercent,
type TransferProgressResponse, type TransferProgressResponse,
} from "@/main-axios.ts"; } from "../api/transfer-api";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Archive, Archive,
@@ -31,13 +31,13 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { import {
getSSHHosts,
getTransferRecent, getTransferRecent,
getTransferMethodPreview, getTransferMethodPreview,
type TransferDestination, type TransferDestination,
type TransferMethodPreference, type TransferMethodPreference,
type TransferMethodPreview, type TransferMethodPreview,
} from "@/main-axios.ts"; } from "../api/transfer-api";
import { getSSHHosts } from "../api/hosts";
import { import {
getSSHStatus, getSSHStatus,
browseSSHDirectory, browseSSHDirectory,
@@ -49,7 +49,7 @@ import {
addFolderShortcut, addFolderShortcut,
removeFolderShortcut, removeFolderShortcut,
} from "../api/file-manager-data-api"; } from "../api/file-manager-data-api";
import type { SSHHost } from "@/types"; import type { SSHHost } from "../host-types";
interface FileItem { interface FileItem {
name: string; name: string;
@@ -1,5 +1,5 @@
import type { FileItem, SSHHost } from "@/types/index"; import type { FileItem, SSHHost } from "./host-types";
import type { LogEntry } from "@/types/connection-log.ts"; import type { LogEntry } from "@termix/plugin-sdk/ui";
export interface FileManagerProps { export interface FileManagerProps {
initialHost?: SSHHost | null; initialHost?: SSHHost | null;
@@ -1,7 +1,7 @@
import { import {
markAdaptiveResourceUsed, markAdaptiveResourceUsed,
runAdaptiveBackgroundTask, runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget"; } from "@termix/plugin-sdk/ui";
export type FilePreviewKind = export type FilePreviewKind =
| "image" | "image"
@@ -6,7 +6,7 @@ import type {
FileManagerWidgetConfig, FileManagerWidgetConfig,
WidgetComponentProps, WidgetComponentProps,
} from "./homepage.js"; } from "./homepage.js";
import type { SSHHost } from "@/types/index"; import type { SSHHost } from "../host-types";
import { FileManager } from "../FileManager"; import { FileManager } from "../FileManager";
export function FileManagerWidget({ export function FileManagerWidget({
@@ -1,7 +1,7 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { downloadSSHFile } from "../api/ssh-file-operations-api"; 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 { interface DragToDesktopState {
isDragging: boolean; isDragging: boolean;
@@ -2,7 +2,7 @@
import { useState, useCallback, useRef } from "react"; import { useState, useCallback, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { downloadSSHFile } from "../api/ssh-file-operations-api"; 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 { interface DragToSystemState {
isDragging: boolean; isDragging: boolean;
@@ -2,8 +2,8 @@ import { useCallback, useRef } from "react";
import { createElement } from "react"; import { createElement } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { FileItem } from "@/types/index"; import type { FileItem } from "../host-types";
import type { LocalWalkResult } from "@/types/electron"; import type { LocalWalkResult } from "@termix/plugin-sdk/ui";
import { import {
cancelLocalTransfer, cancelLocalTransfer,
createLocalTransferId, createLocalTransferId,
@@ -16,7 +16,7 @@ import {
getLocalHome, getLocalHome,
localPathsExist, localPathsExist,
walkLocalPaths, walkLocalPaths,
} from "@/lib/local-files.ts"; } from "../lib/local-files";
import { import {
UnsafeLocalNameError, UnsafeLocalNameError,
buildLocalDestination, buildLocalDestination,
@@ -0,0 +1,57 @@
/** A host as the shell hands it to the file manager. */
export interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
sudoPassword?: string;
forceKeyboardInteractive?: boolean;
credentialId?: number;
overrideCredentialUsername?: boolean;
userId?: string;
jumpHosts?: Array<{ hostId: number }>;
terminalConfig?: Record<string, unknown>;
notes?: string;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown[];
connectionType?: string;
enableSsh?: boolean;
syncId?: string | null;
createdAt: string;
updatedAt: string;
connectionOrigin?: "local" | "remote" | null;
instanceId?: string;
isShared?: boolean;
permissionLevel?: "connect" | "view" | "edit" | "manage";
/** Enabled plugins' host-scope settings, keyed by plugin id. */
pluginSettings?: Record<string, Record<string, unknown>>;
}
export interface FileItem {
name: string;
path: string;
isPinned?: boolean;
type: "file" | "directory" | "link";
sshSessionId?: string;
size?: number;
modified?: string;
modifiedTimestamp?: number;
permissions?: string;
owner?: string;
group?: string;
linkTarget?: string;
executable?: boolean;
}
+9 -4
View File
@@ -15,6 +15,7 @@ import { SftpTransferTab } from "./SftpTransferTab.tsx";
import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx"; import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx";
import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx"; import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx";
import { startTransferMonitor } from "./TransferMonitor.tsx"; import { startTransferMonitor } from "./TransferMonitor.tsx";
import { setFileManagerApp } from "./api/client";
function FilesTab({ tab, host, sshHost, isVisible }: TabProps) { function FilesTab({ tab, host, sshHost, isVisible }: TabProps) {
const data = tab.data as const data = tab.data as
@@ -66,6 +67,8 @@ function openEditorAction(
} }
export function activate(app: TermixApp): void { export function activate(app: TermixApp): void {
setFileManagerApp(app);
app.onDispose(() => setFileManagerApp(null));
app.registerTab("files", FilesTab as unknown as ComponentType<TabProps>, { app.registerTab("files", FilesTab as unknown as ComponentType<TabProps>, {
icon: FolderSearch, icon: FolderSearch,
titleKey: "nav.files", titleKey: "nav.files",
@@ -104,6 +107,7 @@ export function activate(app: TermixApp): void {
order: 20, order: 20,
tabType: "files", tabType: "files",
copyUrlView: "file-manager", copyUrlView: "file-manager",
quickConnect: true,
when: (host) => when: (host) =>
!!host.enableSsh && !!host.enableSsh &&
fileManagerHostSetting(host, "enableFileManager", true), fileManagerHostSetting(host, "enableFileManager", true),
@@ -122,10 +126,11 @@ export function activate(app: TermixApp): void {
editFormComponent: FileManagerWidgetEditForm as never, editFormComponent: FileManagerWidgetEditForm as never,
}); });
app.registerAction("files.openHost", (( const openHost = ((host: PluginHostRecord | null, path?: string) =>
host: PluginHostRecord | null, openHostAction(app, host, path)) as never;
path?: string, app.registerAction("files.openHost", openHost);
) => openHostAction(app, host, path)) as never); // The shell's "open in file manager" tab button asks for this.
app.registerAction("host.openFiles", openHost);
app.registerAction("files.openEditor", (( app.registerAction("files.openEditor", ((
host: PluginHostRecord | null, host: PluginHostRecord | null,
filePath: string, 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 type { FileItem } from "../host-types";
import { createKeyedRequestCache } from "@/lib/keyed-request-cache"; import { createKeyedRequestCache } from "./keyed-request-cache";
export interface FileContentResult { export interface FileContentResult {
content: string; content: string;
@@ -1,5 +1,5 @@
import type { FileItem } from "@/types/index"; import type { FileItem } from "../host-types";
import { createKeyedRequestCache } from "@/lib/keyed-request-cache"; import { createKeyedRequestCache } from "./keyed-request-cache";
export interface FileListResult { export interface FileListResult {
files: FileItem[]; files: FileItem[];
File renamed without changes.
@@ -8,8 +8,8 @@ import type {
LocalFsResult, LocalFsResult,
LocalTrashResult, LocalTrashResult,
LocalWalkResult, LocalWalkResult,
} from "@/types/electron"; } from "@termix/plugin-sdk/ui";
import { isElectron } from "./electron"; import { isElectron } from "@termix/plugin-sdk/ui";
export function isLocalFileBrowserAvailable(): boolean { export function isLocalFileBrowserAvailable(): boolean {
if (!isElectron()) return false; if (!isElectron()) return false;
@@ -1,7 +1,7 @@
// Pure helpers shared by the dual-pane (local <-> remote) file manager code. // Pure helpers shared by the dual-pane (local <-> remote) file manager code.
// Kept free of React/DOM so they can be unit tested directly. // Kept free of React/DOM so they can be unit tested directly.
import type { LocalFileEntry } from "@/types/electron"; import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
/** Custom MIME type carried by drags that originate in the local pane. */ /** Custom MIME type carried by drags that originate in the local pane. */
export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files"; export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files";
@@ -1,4 +1,4 @@
import type { FileItem } from "@/types/index"; import type { FileItem } from "./host-types";
export function childPath(parent: string, name: string): string { export function childPath(parent: string, name: string): string {
return parent === "/" ? `/${name}` : `${parent.replace(/\/$/, "")}/${name}`; return parent === "/" ? `/${name}` : `${parent.replace(/\/$/, "")}/${name}`;
@@ -3,7 +3,7 @@ import {
formatDurationMs, formatDurationMs,
formatTransferMbPerSec, formatTransferMbPerSec,
type TransferTimings, type TransferTimings,
} from "@/main-axios.ts"; } from "./api/transfer-api";
export function createFormatTransferMetrics(t: TFunction) { export function createFormatTransferMetrics(t: TFunction) {
return (timings?: TransferTimings): string => { 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 { toast } from "sonner";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { import {
@@ -9,7 +9,7 @@ import {
createTransferProgressTracker, createTransferProgressTracker,
type TransferProgressResponse, type TransferProgressResponse,
type TransferTimings, type TransferTimings,
} from "@/main-axios.ts"; } from "./api/transfer-api";
import { TransferProgressToast } from "./components/TransferProgressToast.tsx"; import { TransferProgressToast } from "./components/TransferProgressToast.tsx";
import { import {
markTransferNotified, markTransferNotified,
@@ -6,7 +6,7 @@ import {
LOCAL_FILES_DRAG_MIME, LOCAL_FILES_DRAG_MIME,
REMOTE_FILES_DRAG_MIME, REMOTE_FILES_DRAG_MIME,
} from "../../src/frontend/local-transfer-utils"; } from "../../src/frontend/local-transfer-utils";
import type { LocalFileEntry } from "@/types/electron"; import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
// jsdom has no layout, so render every row instead of a virtual window. // jsdom has no layout, so render every row instead of a virtual window.
vi.mock("@tanstack/react-virtual", () => ({ vi.mock("@tanstack/react-virtual", () => ({
@@ -19,13 +19,16 @@ const api = vi.hoisted(() => ({
beginTransferProgressMonitoring: vi.fn(), beginTransferProgressMonitoring: vi.fn(),
})); }));
vi.mock("@/main-axios", () => ({ vi.mock("../../src/frontend/api/transfer-api", () => ({
addTransferRecent: api.addTransferRecent, addTransferRecent: api.addTransferRecent,
getSSHHosts: api.getSSHHosts,
getTransferProgressPercent: api.getTransferProgressPercent, getTransferProgressPercent: api.getTransferProgressPercent,
transferToHost: api.transferToHost, transferToHost: api.transferToHost,
})); }));
vi.mock("../../src/frontend/api/hosts", () => ({
getSSHHosts: api.getSSHHosts,
}));
vi.mock("../../src/frontend/api/ssh-file-operations-api", () => ({ vi.mock("../../src/frontend/api/ssh-file-operations-api", () => ({
browseSSHDirectory: api.browseSSHDirectory, browseSSHDirectory: api.browseSSHDirectory,
changeSSHPermissions: api.changeSSHPermissions, changeSSHPermissions: api.changeSSHPermissions,
@@ -57,11 +57,12 @@ describe(`${manifest.id} activate`, () => {
expect(rendered.registered.hostEditorSections()).toEqual([]); 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 }); rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.actions().sort()).toEqual([ expect(rendered.registered.actions().sort()).toEqual([
"files.openEditor", "files.openEditor",
"files.openHost", "files.openHost",
"host.openFiles",
]); ]);
}); });
@@ -4,9 +4,8 @@ const fileManagerApiMock = vi.hoisted(() => ({
post: vi.fn(async () => ({ data: { complete: false } })), post: vi.fn(async () => ({ data: { complete: false } })),
})); }));
vi.mock("@/main-axios", () => ({ vi.mock("../../../src/frontend/api/client", () => ({
authApi: { defaults: {} }, fileManagerApi: () => ({ defaults: {} }),
fileManagerApi: { defaults: {} },
getFileManagerApiForSession: () => fileManagerApiMock, getFileManagerApiForSession: () => fileManagerApiMock,
handleApiError: (error: unknown) => { handleApiError: (error: unknown) => {
throw error; throw error;
@@ -14,19 +13,20 @@ vi.mock("@/main-axios", () => ({
setSessionOrigin: vi.fn(), setSessionOrigin: vi.fn(),
clearSessionOrigin: vi.fn(), clearSessionOrigin: vi.fn(),
})); }));
vi.mock("@/lib/connection-origin", () => ({ vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<object>()),
resolveConnectionOrigin: vi.fn(), resolveConnectionOrigin: vi.fn(),
})); createFrontendLogger: () => ({
vi.mock("@/lib/frontend-logger", () => ({
fileLogger: {
info: vi.fn(), info: vi.fn(),
success: 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(), getCachedFileList: vi.fn(),
})); }));
vi.mock("@/lib/file-content-request-cache", () => ({ vi.mock("../../../src/frontend/lib/file-content-request-cache", () => ({
getCachedFileContent: vi.fn(), getCachedFileContent: vi.fn(),
invalidateCachedFileContent: vi.fn(), invalidateCachedFileContent: vi.fn(),
})); }));
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; 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()); afterEach(() => vi.useRealTimers());
@@ -26,7 +26,7 @@ import {
serializeLocalFilesDragPayload, serializeLocalFilesDragPayload,
sortLocalEntries, sortLocalEntries,
} from "../../src/frontend/local-transfer-utils"; } from "../../src/frontend/local-transfer-utils";
import type { LocalFileEntry } from "@/types/electron"; import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
describe("drag payloads", () => { describe("drag payloads", () => {
it("round-trips local file payloads", () => { it("round-trips local file payloads", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { FileItem } from "@/types/index"; import type { FileItem } from "../../src/frontend/host-types";
import { import {
addOptimisticItem, addOptimisticItem,
childPath, 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", () => ({ vi.mock("../../src/frontend/transferNotificationStore.ts", () => ({
markTransferNotified: vi.fn(), markTransferNotified: vi.fn(),
})); }));
-6
View File
@@ -1,10 +1,4 @@
{ {
"extends": "@termix/plugin-sdk/tsconfig.plugin.json", "extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
} }
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "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" "validate": "termix-plugin validate"
}, },
"devDependencies": { "devDependencies": {
-6
View File
@@ -1,10 +1,4 @@
{ {
"extends": "@termix/plugin-sdk/tsconfig.plugin.json", "extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
} }
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "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" "validate": "termix-plugin validate"
}, },
"devDependencies": { "devDependencies": {
@@ -1,12 +0,0 @@
import React from "react";
import { HomepageCanvas } from "./HomepageCanvas";
const HomepageApp: React.FC = () => {
return (
<div className="h-screen w-screen overflow-hidden">
<HomepageCanvas fitOnLoad={true} />
</div>
);
};
export default HomepageApp;
-77
View File
@@ -1,77 +0,0 @@
import { useSyncExternalStore } from "react";
/**
* A keyed, reactive list of contributions.
*
* Widgets register on import, so anything rendered from a registry has to
* re-render when it changes. The snapshot array is cached until the next
* change because useSyncExternalStore compares by identity and loops forever
* on a fresh array.
*/
export interface Registry<T extends { id: string }> {
/** Adds or replaces by id. The disposer only removes this exact entry. */
register: (item: T) => () => void;
unregister: (id: string) => void;
get: (id: string) => T | undefined;
list: () => T[];
subscribe: (listener: () => void) => () => void;
/** React hook over list(). */
useList: () => T[];
reset: () => void;
}
export function createRegistry<T extends { id: string }>(
sort?: (a: T, b: T) => number,
): Registry<T> {
const items = new Map<string, T>();
const listeners = new Set<() => void>();
let snapshot: T[] | null = null;
const emit = () => {
snapshot = null;
for (const listener of listeners) listener();
};
const list = () => {
if (!snapshot) {
snapshot = [...items.values()];
if (sort) snapshot.sort(sort);
}
return snapshot;
};
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
return {
register(item) {
items.set(item.id, item);
emit();
return () => {
if (items.get(item.id) === item) {
items.delete(item.id);
emit();
}
};
},
unregister(id) {
if (items.delete(id)) emit();
},
get(id) {
return items.get(id);
},
list,
subscribe,
useList() {
return useSyncExternalStore(subscribe, list, list);
},
reset() {
items.clear();
emit();
},
};
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "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" "validate": "termix-plugin validate"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "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" "validate": "termix-plugin validate"
}, },
"dependencies": { "dependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "termix-plugin build", "build": "termix-plugin build",
"test": "termix-plugin test", "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" "validate": "termix-plugin validate"
}, },
"dependencies": { "dependencies": {
Loaded 100 of 399 files, more files were not shown because too many files have changed in this diff. Show more