mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||
@@ -48,11 +50,110 @@ export function externalRequireInterop(externals) {
|
||||
};
|
||||
}
|
||||
|
||||
const SDK_PATCHES = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"patches",
|
||||
);
|
||||
|
||||
/**
|
||||
* Patches a dependency before it is bundled, so the fix ships inside the
|
||||
* plugin rather than depending on whoever ran npm install.
|
||||
*
|
||||
* package.json "termix": { "patches": ["xterm-android-ime"] } names patches
|
||||
* the SDK ships for libraries several plugins share. Any .cjs file in the
|
||||
* plugin's own patches/ folder runs too. Every patch must be safe to run
|
||||
* twice.
|
||||
*/
|
||||
export function applyPatches(cwd, pluginId) {
|
||||
const pkgPath = path.join(cwd, "package.json");
|
||||
const pkg = fs.existsSync(pkgPath)
|
||||
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
|
||||
: {};
|
||||
const scripts = [];
|
||||
for (const name of pkg.termix?.patches ?? []) {
|
||||
const file = path.join(SDK_PATCHES, `${name}.cjs`);
|
||||
if (!/^[a-z0-9-]+$/.test(name) || !fs.existsSync(file)) {
|
||||
throw new Error(`${pluginId}: the SDK has no patch named "${name}"`);
|
||||
}
|
||||
scripts.push(file);
|
||||
}
|
||||
const own = path.join(cwd, "patches");
|
||||
if (fs.existsSync(own)) {
|
||||
for (const file of fs.readdirSync(own).sort()) {
|
||||
if (file.endsWith(".cjs")) scripts.push(path.join(own, file));
|
||||
}
|
||||
}
|
||||
for (const file of scripts) {
|
||||
const result = spawnSync(process.execPath, [file], {
|
||||
cwd,
|
||||
env: { ...process.env, TERMIX_PATCH_ROOT: cwd },
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${pluginId}: patch ${path.basename(file)} failed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const THEME_CSS = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"dist",
|
||||
"host",
|
||||
"theme.css",
|
||||
);
|
||||
|
||||
/**
|
||||
* Compiles the Tailwind classes the plugin's frontend uses into
|
||||
* dist/frontend.css, against the theme core ships in the SDK. Core only
|
||||
* compiles its own classes, so a class no core file happens to use would
|
||||
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
|
||||
* produced (a library's stylesheet the plugin imports).
|
||||
*/
|
||||
export async function buildTailwind(cwd, outDir) {
|
||||
const { compile, optimize } = await import("@tailwindcss/node");
|
||||
const { Scanner } = await import("@tailwindcss/oxide");
|
||||
if (!fs.existsSync(THEME_CSS)) {
|
||||
throw new Error("The SDK theme is missing. Run: npm run build:sdk");
|
||||
}
|
||||
const input = [
|
||||
'@import "tailwindcss/theme.css" theme(reference);',
|
||||
`@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`,
|
||||
'@import "tailwindcss/utilities.css" layer(utilities);',
|
||||
].join("\n");
|
||||
const compiler = await compile(input, {
|
||||
base: path.dirname(THEME_CSS),
|
||||
onDependency: () => {},
|
||||
});
|
||||
const scanner = new Scanner({
|
||||
sources: [
|
||||
{
|
||||
base: path.join(cwd, "src", "frontend"),
|
||||
pattern: "**/*",
|
||||
negated: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Flattened and minified the way core's own CSS is, so it does not rely
|
||||
// on native CSS nesting.
|
||||
const css = optimize(compiler.build(scanner.scan()), {
|
||||
minify: true,
|
||||
}).code.trim();
|
||||
if (!css) return;
|
||||
const target = path.join(outDir, "frontend.css");
|
||||
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
|
||||
fs.writeFileSync(target, `${existing}${existing ? "\n" : ""}${css}\n`);
|
||||
}
|
||||
|
||||
export async function build({ cwd }) {
|
||||
const manifest = readManifest(cwd);
|
||||
const pluginId = manifest.id ?? path.basename(cwd);
|
||||
const outDir = path.join(cwd, "dist");
|
||||
|
||||
applyPatches(cwd, pluginId);
|
||||
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
@@ -123,6 +224,8 @@ export async function build({ cwd }) {
|
||||
});
|
||||
}
|
||||
|
||||
if (frontendEntry) await buildTailwind(cwd, outDir);
|
||||
|
||||
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
|
||||
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { LEGACY_TABLE_OWNERS as LEGACY_TABLES } from "../../dist/db.js";
|
||||
import { findUnownedTableWrites } from "../../dist/ddl.js";
|
||||
import { collectOwnedIndexes, findUnownedTableWrites } from "../../dist/ddl.js";
|
||||
|
||||
/**
|
||||
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
||||
@@ -39,6 +39,7 @@ export async function validate({ cwd }) {
|
||||
|
||||
problems.push(...validateMigrations(cwd, raw.id ?? path.basename(cwd)));
|
||||
problems.push(...validateNativeDependencies(cwd, raw));
|
||||
problems.push(...validatePackage(cwd, raw));
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
@@ -48,6 +49,36 @@ export async function validate({ cwd }) {
|
||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* package.json has to agree with the manifest: the same version, and a real
|
||||
* SDK range rather than "*", so a plugin built against a newer SDK says so.
|
||||
*/
|
||||
function validatePackage(cwd, raw) {
|
||||
const problems = [];
|
||||
const pkgPath = path.join(cwd, "package.json");
|
||||
if (!fs.existsSync(pkgPath)) return problems;
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
||||
|
||||
if (pkg.version && raw.version && pkg.version !== raw.version) {
|
||||
problems.push(
|
||||
`package.json version ${pkg.version} does not match manifest version ${raw.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sdkRange =
|
||||
pkg.peerDependencies?.["@termix/plugin-sdk"] ??
|
||||
pkg.dependencies?.["@termix/plugin-sdk"] ??
|
||||
pkg.devDependencies?.["@termix/plugin-sdk"];
|
||||
if (!sdkRange) {
|
||||
problems.push("package.json does not depend on @termix/plugin-sdk");
|
||||
} else if (sdkRange === "*" || sdkRange === "latest") {
|
||||
problems.push(
|
||||
`@termix/plugin-sdk is "${sdkRange}"; pin a range such as "^1.0.0"`,
|
||||
);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A native dependency has to actually be a real dependency of the plugin, or
|
||||
* the build's external declaration points at nothing node_modules can
|
||||
@@ -106,16 +137,25 @@ function validateMigrations(cwd, pluginId) {
|
||||
.filter((file) => file.endsWith(".sql"))
|
||||
.sort();
|
||||
byDialect.set(dialect, files);
|
||||
const sqls = files.map((file) =>
|
||||
fs.readFileSync(path.join(dir, file), "utf8"),
|
||||
);
|
||||
const indexes = collectOwnedIndexes(pluginId, sqls, legacy);
|
||||
|
||||
for (const file of files) {
|
||||
for (const [index, file] of files.entries()) {
|
||||
if (!/^\d{4}_[a-z0-9_]+\.sql$/.test(file)) {
|
||||
problems.push(
|
||||
`migrations/${dialect}/${file} must be named NNNN_name.sql, lower snake_case`,
|
||||
);
|
||||
}
|
||||
|
||||
const sql = fs.readFileSync(path.join(dir, file), "utf8");
|
||||
for (const problem of findUnownedTableWrites(pluginId, sql, legacy)) {
|
||||
const sql = sqls[index];
|
||||
for (const problem of findUnownedTableWrites(
|
||||
pluginId,
|
||||
sql,
|
||||
legacy,
|
||||
indexes,
|
||||
)) {
|
||||
problems.push(`migrations/${dialect}/${file} ${problem}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import process from "node:process";
|
||||
import { build } from "./commands/build.mjs";
|
||||
import { applyPatches, build } from "./commands/build.mjs";
|
||||
import { readManifest } from "./lib/plugin-dir.mjs";
|
||||
import { validate } from "./commands/validate.mjs";
|
||||
import { test } from "./commands/test.mjs";
|
||||
import { pack } from "./commands/pack.mjs";
|
||||
@@ -15,6 +16,7 @@ import { migrations } from "./commands/migrations.mjs";
|
||||
|
||||
const COMMANDS = {
|
||||
build,
|
||||
patch: async ({ cwd }) => applyPatches(cwd, readManifest(cwd).id),
|
||||
validate,
|
||||
test,
|
||||
pack,
|
||||
@@ -32,6 +34,7 @@ if (!command || command === "--help" || command === "-h") {
|
||||
"Usage: termix-plugin <command>",
|
||||
"",
|
||||
" build Bundle the plugin into dist/",
|
||||
" patch Apply the plugin's dependency patches (build does this too)",
|
||||
" validate Check manifest.json and the files it names",
|
||||
" test Run the plugin's vitest suite",
|
||||
" pack Write <id>-<version>.tmxplug of the built plugin [--out dir]",
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
* a second ssh2 would mean a second set of native bindings. A plugin may
|
||||
* depend on anything outside these lists, and that does get bundled.
|
||||
*
|
||||
* A plugin lists the ones it uses as peerDependencies. Only add a package here
|
||||
* when core itself ships it; a library one plugin needs belongs in that
|
||||
* plugin's own dependencies, so it can update without a core release.
|
||||
*
|
||||
* Node builtins are external automatically through platform: "node".
|
||||
*/
|
||||
|
||||
@@ -18,9 +22,6 @@ export const BACKEND_EXTERNALS = [
|
||||
"multer",
|
||||
"cookie-parser",
|
||||
"axios",
|
||||
"jszip",
|
||||
"guacamole-lite",
|
||||
"@anthropic-ai/sdk",
|
||||
"drizzle-orm",
|
||||
"drizzle-orm/*",
|
||||
// Native image processing, loaded on first use by the terminal's image upload.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
|
||||
// the package resolves the way the plugin's bundle will resolve it.
|
||||
function packageDir(name) {
|
||||
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
|
||||
// Walked by hand: a package whose exports map hides package.json cannot be
|
||||
// found with require.resolve.
|
||||
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
|
||||
const candidate = path.join(dir, "node_modules", name);
|
||||
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
|
||||
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
|
||||
}
|
||||
}
|
||||
|
||||
const packageRoot = path.join(packageDir("guacamole-common-js"));
|
||||
|
||||
const bundlePaths = [
|
||||
path.join(packageRoot, "dist", "esm", "guacamole-common.js"),
|
||||
path.join(packageRoot, "dist", "cjs", "guacamole-common.js"),
|
||||
];
|
||||
|
||||
const oldFlushBlock =
|
||||
" if (window.requestAnimationFrame && document.hasFocus())\n" +
|
||||
" asyncFlush();\n" +
|
||||
" else\n" +
|
||||
" syncFlush();";
|
||||
|
||||
const newFlushBlock =
|
||||
" // Electron can throttle or skip requestAnimationFrame() for inactive\n" +
|
||||
" // windows/tabs even while guacd is still sending display frames. Flush\n" +
|
||||
" // synchronously so Guacamole connections do not stall while waiting for\n" +
|
||||
" // a frame callback that may never run.\n" +
|
||||
" syncFlush();";
|
||||
|
||||
let patched = false;
|
||||
let foundBundle = false;
|
||||
|
||||
for (const bundlePath of bundlePaths) {
|
||||
if (!fs.existsSync(bundlePath)) {
|
||||
console.log(
|
||||
`[patch-guacamole-common-js] ${bundlePath} not found, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
foundBundle = true;
|
||||
let content = fs.readFileSync(bundlePath, "utf8");
|
||||
if (content.includes(newFlushBlock)) continue;
|
||||
|
||||
if (!content.includes(oldFlushBlock)) {
|
||||
console.log(
|
||||
`[patch-guacamole-common-js] Flush target not found in ${bundlePath}, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
content = content.replace(oldFlushBlock, newFlushBlock);
|
||||
fs.writeFileSync(bundlePath, content);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!foundBundle) {
|
||||
console.log("[patch-guacamole-common-js] File not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!patched) {
|
||||
console.log("[patch-guacamole-common-js] Already patched");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[patch-guacamole-common-js] Patched display flush to avoid Electron requestAnimationFrame stalls",
|
||||
);
|
||||
@@ -0,0 +1,130 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
|
||||
// the package resolves the way the plugin's bundle will resolve it.
|
||||
function packageDir(name) {
|
||||
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
|
||||
// Walked by hand: a package whose exports map hides package.json cannot be
|
||||
// found with require.resolve.
|
||||
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
|
||||
const candidate = path.join(dir, "node_modules", name);
|
||||
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
|
||||
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
|
||||
}
|
||||
}
|
||||
|
||||
const xtermDir = path.join(packageDir("@xterm/xterm"), "lib");
|
||||
|
||||
// Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while
|
||||
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
|
||||
// composition on the previous word and replace it with a shorter value (for
|
||||
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
|
||||
//
|
||||
// Also fixes _handleAnyTextareaChanges, which iOS Safari/WKWebView drives
|
||||
// for ordinary typing (it reports keyCode 229 for all software-keyboard
|
||||
// input, not just IME composition). That handler diffs the textarea value
|
||||
// via `newValue.replace(oldValue, "")`, a literal substring removal. When
|
||||
// keystrokes arrive faster than the function's setTimeout(0) callback runs,
|
||||
// several overlapping callbacks each capture a stale oldValue, so the
|
||||
// literal-substring search fails to match and the diff silently comes back
|
||||
// empty - characters are dropped instead of sent. Swap in the same
|
||||
// common-prefix diff used for composition-end above so a stale oldValue
|
||||
// still yields the correct delta.
|
||||
const patches = [
|
||||
{
|
||||
file: "xterm.mjs",
|
||||
replacements: [
|
||||
[
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||
],
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
|
||||
],
|
||||
[
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
|
||||
],
|
||||
[
|
||||
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
|
||||
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};const s=this._preCompositionValue;this._isSendingComposition=!0",
|
||||
],
|
||||
[
|
||||
"e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
|
||||
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
|
||||
],
|
||||
[
|
||||
[
|
||||
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
|
||||
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
|
||||
],
|
||||
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout(()=>{const t=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "xterm.js",
|
||||
replacements: [
|
||||
[
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||
],
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
|
||||
],
|
||||
[
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
|
||||
],
|
||||
[
|
||||
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
|
||||
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end},i=this._preCompositionValue;this._isSendingComposition=!0",
|
||||
],
|
||||
[
|
||||
"e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
|
||||
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
|
||||
],
|
||||
[
|
||||
[
|
||||
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
|
||||
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
|
||||
],
|
||||
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout((()=>{const e=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { file, replacements } of patches) {
|
||||
const filePath = path.join(xtermDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`[patch-xterm-android-ime] Missing ${filePath}`);
|
||||
}
|
||||
|
||||
let source = fs.readFileSync(filePath, "utf8");
|
||||
let changed = false;
|
||||
|
||||
for (const [original, patched] of replacements) {
|
||||
if (source.includes(patched)) {
|
||||
continue;
|
||||
}
|
||||
const originals = Array.isArray(original) ? original : [original];
|
||||
const matched = originals.find((candidate) => source.includes(candidate));
|
||||
if (!matched) {
|
||||
throw new Error(
|
||||
`[patch-xterm-android-ime] Expected source not found in ${file}`,
|
||||
);
|
||||
}
|
||||
source = source.replace(matched, patched);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
console.log(`[patch-xterm-android-ime] ${file} already patched`);
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, source);
|
||||
console.log(`[patch-xterm-android-ime] Patched ${file}`);
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Import forms `termix-plugin build` understands beyond plain modules. Picked
|
||||
* up through tsconfig.plugin.json.
|
||||
*/
|
||||
|
||||
/** A script copied next to the bundle; the import is its URL. */
|
||||
declare module "*?url" {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
|
||||
/** A stylesheet imported for its side effect; the build emits frontend.css. */
|
||||
declare module "*.css";
|
||||
@@ -37,6 +37,10 @@
|
||||
"types": "./dist/frontend.d.ts",
|
||||
"default": "./dist/frontend.js"
|
||||
},
|
||||
"./desktop": {
|
||||
"types": "./dist/desktop.d.ts",
|
||||
"default": "./dist/desktop.js"
|
||||
},
|
||||
"./manifest": {
|
||||
"types": "./dist/manifest.d.ts",
|
||||
"default": "./dist/manifest.js"
|
||||
@@ -70,29 +74,46 @@
|
||||
"default": "./dist/ssh-certs.js"
|
||||
},
|
||||
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
|
||||
"./schema/manifest.schema.json": "./schema/manifest.schema.json"
|
||||
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
|
||||
"./ui": {
|
||||
"types": "./dist/host/ui.d.ts",
|
||||
"default": "./dist/host/ui.js"
|
||||
},
|
||||
"./testing-host": {
|
||||
"default": "./dist/host/testing-host.js"
|
||||
},
|
||||
"./theme.css": "./dist/host/theme.css"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"cli",
|
||||
"schema",
|
||||
"tsconfig.plugin.json",
|
||||
"README.md"
|
||||
"README.md",
|
||||
"client.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "^4.2.4",
|
||||
"@tailwindcss/oxide": "^4.2.4",
|
||||
"esbuild": "^0.28.1",
|
||||
"semver": "^7.6.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tar": "^7.5.22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"drizzle-orm": "*",
|
||||
"ssh2": "*",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"better-sqlite3": "*",
|
||||
"drizzle-orm": "*",
|
||||
"i18next": "^26.4.2",
|
||||
"react": "*",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.14",
|
||||
"sonner": "^2.0.8",
|
||||
"ssh2": "*",
|
||||
"vitest": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -110,6 +131,9 @@
|
||||
},
|
||||
"vitest": {
|
||||
"optional": true
|
||||
},
|
||||
"@testing-library/react": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -82,12 +82,12 @@
|
||||
"termix": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Display string only. Never enforced."
|
||||
"description": "Semver range of Termix releases the plugin runs on, e.g. \">=2.9.0\"."
|
||||
},
|
||||
"api": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+$",
|
||||
"description": "Plugin SDK major version. The real compatibility gate."
|
||||
"minLength": 1,
|
||||
"description": "Semver range of the plugin API it needs, e.g. \"1\" or \"^1.2\"."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -115,6 +115,8 @@
|
||||
"notify:send",
|
||||
"audit:read",
|
||||
"desktop:window",
|
||||
"users:impersonate",
|
||||
"db:core-refs",
|
||||
"hosts:read",
|
||||
"db:own",
|
||||
"kv:own",
|
||||
@@ -290,7 +292,9 @@
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,31}$",
|
||||
"not": { "enum": ["ssh"] }
|
||||
"not": {
|
||||
"enum": ["ssh"]
|
||||
}
|
||||
},
|
||||
"credentialFields": {
|
||||
"type": "array",
|
||||
@@ -311,7 +315,9 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"secret": { "type": "boolean" }
|
||||
"secret": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -323,7 +329,9 @@
|
||||
"hostLoginFallback": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "enum": ["username", "password"] }
|
||||
"items": {
|
||||
"enum": ["username", "password"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,17 +360,28 @@
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9]{0,31}$",
|
||||
"not": { "const": "type" }
|
||||
"not": {
|
||||
"const": "type"
|
||||
}
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type"],
|
||||
"properties": {
|
||||
"type": { "enum": ["string", "boolean"] },
|
||||
"required": { "type": "boolean" },
|
||||
"pattern": { "type": "string" },
|
||||
"maxLength": { "type": "integer", "minimum": 1 },
|
||||
"type": {
|
||||
"enum": ["string", "boolean"]
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string"
|
||||
},
|
||||
"maxLength": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"syncEntity": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
|
||||
@@ -894,6 +913,15 @@
|
||||
"ownerOnly": {
|
||||
"type": "boolean",
|
||||
"description": "Only the host's owner may change it."
|
||||
},
|
||||
"secretKeys": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "JSON fields only: keys inside the stored object that hold secrets."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -962,7 +990,9 @@
|
||||
"type": "string",
|
||||
"pattern": "^(/[A-Za-z0-9_.-]+)+$"
|
||||
},
|
||||
"status": { "enum": [307, 308] }
|
||||
"status": {
|
||||
"enum": [307, 308]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1041,6 +1071,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uses": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"description": "Other plugins' action, slot and extension point ids this plugin calls, fills or extends.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -96,8 +96,8 @@ export interface PluginFiles {
|
||||
* to query through `db`. Both require db:own.
|
||||
*
|
||||
* Scoped by name: every table carries the p_<id>_ prefix, and `refs` exposes
|
||||
* users, ssh_data, roles and user_roles read-only so a plugin can join against
|
||||
* them without being able to write them. In-process code could reach around
|
||||
* users, ssh_data, roles and user_roles (behind db:core-refs) so a plugin can
|
||||
* join against them. In-process code could reach around
|
||||
* all of this. The capability, the prefix, lint and review are the contract,
|
||||
* not a sandbox.
|
||||
*/
|
||||
@@ -106,7 +106,10 @@ export interface PluginDatabase {
|
||||
define: <T = unknown>(definition: PluginTableDefinition) => Promise<T>;
|
||||
/** Drizzle handle, scoped to this plugin's tables. */
|
||||
client: <T = unknown>() => Promise<T>;
|
||||
/** Read-only references to the core tables a plugin may point at. */
|
||||
/**
|
||||
* Core tables a plugin may join against: users, hosts, roles, userRoles.
|
||||
* Needs db:core-refs. Treat them as read-only.
|
||||
*/
|
||||
refs: <T = unknown>() => Promise<T>;
|
||||
/**
|
||||
* Flushes writes to disk. Call it after every write: on SQLite the database
|
||||
@@ -235,6 +238,11 @@ export interface PluginSync {
|
||||
registerEntity: (entity: SyncEntityRegistration) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain key/value hand-off between plugins. `provide` and `revoke` only take
|
||||
* keys under the plugin's own id (`<pluginId>.something`); anyone may
|
||||
* `consume`.
|
||||
*/
|
||||
export interface PluginRegistry {
|
||||
provide: <T>(key: string, value: T) => void;
|
||||
consume: <T>(key: string) => T | undefined;
|
||||
@@ -252,7 +260,11 @@ export interface PluginServices {
|
||||
implementation: T,
|
||||
options?: { name?: string },
|
||||
) => void;
|
||||
/** `provider` picks a named provider; omitted, the unnamed one. */
|
||||
/**
|
||||
* The service must be listed in the manifest's `requires` (or be one this
|
||||
* plugin provides). `provider` picks a named provider; omitted, the unnamed
|
||||
* one. A `userId` other than the acting user needs users:impersonate.
|
||||
*/
|
||||
get: <T extends object>(
|
||||
service: string,
|
||||
options?: { userId?: string; provider?: string },
|
||||
@@ -1340,6 +1352,12 @@ export interface PluginLoginInstance {
|
||||
* route ("oidc", "github", "google", "ldap"). Leave unset otherwise.
|
||||
*/
|
||||
type?: string;
|
||||
/**
|
||||
* Redirect methods: the login page starts this instance without a click,
|
||||
* for a server that signs everyone in through one provider. The first
|
||||
* instance that asks wins.
|
||||
*/
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginLoginMethod {
|
||||
@@ -1468,7 +1486,13 @@ export interface PluginOpenIsolatedWindowRequest {
|
||||
* Electron only: rejects when the server is not running embedded in the
|
||||
* desktop app. Needs desktop:window.
|
||||
*/
|
||||
export interface PluginNativeRdpRequest {
|
||||
/**
|
||||
* A host to open in the operating system's own client for a protocol. The
|
||||
* desktop app supports "rdp" (mstsc on Windows) today; any other protocol is
|
||||
* refused.
|
||||
*/
|
||||
export interface PluginExternalClientRequest {
|
||||
protocol: string;
|
||||
host: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
@@ -1480,12 +1504,11 @@ export interface PluginDesktop {
|
||||
request: PluginOpenIsolatedWindowRequest,
|
||||
) => Promise<{ success: true }>;
|
||||
/**
|
||||
* Opens the operating system's own RDP client (mstsc on Windows) for a
|
||||
* host. The password is never passed; the client asks for it. Windows
|
||||
* desktop app only.
|
||||
* Opens the operating system's own client for a protocol. The password is
|
||||
* never passed; the client asks for it. Desktop app only.
|
||||
*/
|
||||
launchNativeRdp: (
|
||||
request: PluginNativeRdpRequest,
|
||||
launchExternalClient: (
|
||||
request: PluginExternalClientRequest,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
/** Whether the server runs embedded in the desktop app, so the calls above can work. */
|
||||
available: () => boolean;
|
||||
@@ -1942,8 +1965,8 @@ export interface PluginContext {
|
||||
* no request behind it. Always audited, and every core API inside still
|
||||
* applies that user's RBAC.
|
||||
*
|
||||
* This is the only way a plugin can name a user. Nothing else trusts a user
|
||||
* id that came from plugin code.
|
||||
* Needs users:impersonate. That and a `userId` option on services.get or
|
||||
* secrets.getShared are the only ways a plugin can name a user.
|
||||
*/
|
||||
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("ssh:connect", "high"),
|
||||
entry("process:spawn", "high"),
|
||||
entry("users:write", "high"),
|
||||
entry("users:impersonate", "high"),
|
||||
entry("db:core-refs", "high"),
|
||||
entry("auth:provide", "high"),
|
||||
entry("device:serial", "high"),
|
||||
entry("notify:hub", "high"),
|
||||
|
||||
@@ -429,7 +429,35 @@ const REFUSED_STATEMENTS = new Set([
|
||||
|
||||
/** What a migration may CREATE, ALTER or DROP. Anything else is refused. */
|
||||
const TABLE_OBJECTS = new Set(["TABLE", "VIEW"]);
|
||||
const HARMLESS_OBJECTS = new Set(["SEQUENCE", "TYPE"]);
|
||||
/** Allowed, but only under the plugin's own prefix. */
|
||||
const PREFIXED_OBJECTS = new Set(["SEQUENCE", "TYPE"]);
|
||||
|
||||
/**
|
||||
* Functions that read files, change server settings or run a string as SQL.
|
||||
* A SELECT calling one of them could do anything, so it is refused.
|
||||
*/
|
||||
const REFUSED_FUNCTIONS = new Set([
|
||||
"SET_CONFIG",
|
||||
"PG_READ_FILE",
|
||||
"PG_READ_BINARY_FILE",
|
||||
"PG_LS_DIR",
|
||||
"PG_STAT_FILE",
|
||||
"PG_FILE_WRITE",
|
||||
"LO_IMPORT",
|
||||
"LO_EXPORT",
|
||||
"LO_FROM_BYTEA",
|
||||
"DBLINK",
|
||||
"DBLINK_EXEC",
|
||||
"QUERY_TO_XML",
|
||||
"QUERY_TO_XML_AND_XMLSCHEMA",
|
||||
"PG_TERMINATE_BACKEND",
|
||||
"PG_CANCEL_BACKEND",
|
||||
"PG_RELOAD_CONF",
|
||||
"LOAD_FILE",
|
||||
"LOAD_EXTENSION",
|
||||
"SYS_EXEC",
|
||||
"SYS_EVAL",
|
||||
]);
|
||||
|
||||
const CREATE_MODIFIERS = [
|
||||
"OR",
|
||||
@@ -460,9 +488,27 @@ export function findUnownedTableWrites(
|
||||
pluginId: string,
|
||||
sql: string,
|
||||
ownedLegacy: ReadonlySet<string> = new Set(),
|
||||
ownedIndexes: ReadonlySet<string> = new Set(),
|
||||
): string[] {
|
||||
const prefix = tablePrefix(pluginId);
|
||||
const problems: string[] = [];
|
||||
const indexes = new Set([
|
||||
...ownedIndexes,
|
||||
...collectOwnedIndexes(pluginId, [sql], ownedLegacy),
|
||||
]);
|
||||
|
||||
// The splitter and tokenizer follow standard quoting only. MySQL backslash
|
||||
// escapes, E'' strings and Postgres dollar quoting would let a string end
|
||||
// somewhere else for the engine than for this check, so none are allowed.
|
||||
if (sql.includes("\\")) {
|
||||
problems.push("backslashes are not allowed in a plugin migration");
|
||||
}
|
||||
if (/\$[A-Za-z_0-9]*\$/.test(sql)) {
|
||||
problems.push(
|
||||
"dollar-quoted strings are not allowed in a plugin migration",
|
||||
);
|
||||
}
|
||||
if (problems.length > 0) return problems;
|
||||
|
||||
for (const statement of splitStatements(sql)) {
|
||||
const tokens = tokenize(statement);
|
||||
@@ -507,16 +553,47 @@ export function findUnownedTableWrites(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first !== "CREATE" && find("CASCADE") > 0) {
|
||||
problems.push("CASCADE is not allowed in a plugin migration");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first === "CREATE" || first === "ALTER" || first === "DROP") {
|
||||
let at = skip(1, CREATE_MODIFIERS);
|
||||
const object = upper(at);
|
||||
if (object === "INDEX") {
|
||||
// The index name is free; the table after ON is what it writes to.
|
||||
const on = find("ON", at);
|
||||
if (on > 0) table(skip(on + 1, ["ONLY"]));
|
||||
if (on > 0) {
|
||||
table(skip(on + 1, ["ONLY"]));
|
||||
} else if (first === "CREATE") {
|
||||
problems.push("CREATE INDEX without ON is not allowed");
|
||||
} else {
|
||||
// DROP INDEX name: only an index this plugin created.
|
||||
const nameAt = skip(at + 1, ["IF", "EXISTS", "CONCURRENTLY"]);
|
||||
const name = tokens[nameAt];
|
||||
if (
|
||||
!name ||
|
||||
name.kind === "punct" ||
|
||||
tokens[nameAt + 1]?.value === "." ||
|
||||
(!name.value.startsWith(prefix) && !indexes.has(name.value))
|
||||
) {
|
||||
problems.push(
|
||||
`${first} INDEX "${name?.value ?? ""}" is not an index this plugin created`,
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (PREFIXED_OBJECTS.has(object)) {
|
||||
const name = tokens[skip(at + 1, ["IF", "NOT", "EXISTS"])];
|
||||
if (!name || !name.value.startsWith(prefix)) {
|
||||
problems.push(
|
||||
`${first} ${object} "${name?.value ?? ""}" is not prefixed "${prefix}"`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (HARMLESS_OBJECTS.has(object)) continue;
|
||||
if (!TABLE_OBJECTS.has(object)) {
|
||||
problems.push(
|
||||
`${first} ${object || tokens[at]?.value || ""} is not allowed in a plugin migration`.trim(),
|
||||
@@ -565,6 +642,14 @@ export function findUnownedTableWrites(
|
||||
}
|
||||
|
||||
if (first === "UPDATE") {
|
||||
// MySQL writes every table before SET: UPDATE a JOIN b ... SET b.x = 1.
|
||||
const set = find("SET");
|
||||
const end = set > 0 ? set : tokens.length;
|
||||
for (let at = 1; at < end; at++) {
|
||||
if (upper(at) === "JOIN" || tokens[at].value === ",") {
|
||||
table(at + 1);
|
||||
}
|
||||
}
|
||||
table(
|
||||
skip(1, [
|
||||
"OR",
|
||||
@@ -582,12 +667,51 @@ export function findUnownedTableWrites(
|
||||
|
||||
if (first === "DELETE") {
|
||||
const from = find("FROM");
|
||||
if (from > 0) table(skip(from + 1, ["ONLY"]));
|
||||
else problems.push("DELETE without FROM is not allowed");
|
||||
if (from < 0) {
|
||||
problems.push("DELETE without FROM is not allowed");
|
||||
continue;
|
||||
}
|
||||
// MySQL: DELETE a, b FROM ... deletes from a and b.
|
||||
let at = skip(1, ["LOW_PRIORITY", "QUICK", "IGNORE"]);
|
||||
if (at < from) {
|
||||
while (at < from) {
|
||||
at = table(at);
|
||||
if (tokens[at]?.value !== ",") break;
|
||||
at++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
at = table(skip(from + 1, ["ONLY"]));
|
||||
// DELETE FROM a, b USING ... deletes from both.
|
||||
while (tokens[at]?.value === ",") at = table(at + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first !== "SELECT" && first !== "COMMENT") {
|
||||
if (first === "SELECT") {
|
||||
// SELECT ... INTO creates a table on Postgres and writes a file on
|
||||
// MySQL.
|
||||
if (find("INTO") > 0) {
|
||||
problems.push("SELECT INTO is not allowed in a plugin migration");
|
||||
continue;
|
||||
}
|
||||
for (let at = 0; at < tokens.length; at++) {
|
||||
if (REFUSED_FUNCTIONS.has(upper(at)) && tokens[at + 1]?.value === "(") {
|
||||
problems.push(
|
||||
`${tokens[at].value}() is not allowed in a plugin migration`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// setval can break a core table's ids, so only the plugin's own.
|
||||
for (const match of statement.matchAll(/setval\s*\(\s*([^,]*)/gi)) {
|
||||
const target = /'([^']*)'/.exec(match[1])?.[1];
|
||||
if (!target || !target.startsWith(prefix)) {
|
||||
problems.push("setval() may only target this plugin's own sequences");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first !== "COMMENT") {
|
||||
problems.push(
|
||||
`"${tokens[0].value}" statements are not allowed in a plugin migration`,
|
||||
);
|
||||
@@ -596,3 +720,44 @@ export function findUnownedTableWrites(
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of the indexes a plugin's migrations create on its own tables, so a
|
||||
* later DROP INDEX with no ON clause can be checked against them.
|
||||
*/
|
||||
export function collectOwnedIndexes(
|
||||
pluginId: string,
|
||||
sqls: readonly string[],
|
||||
ownedLegacy: ReadonlySet<string> = new Set(),
|
||||
): Set<string> {
|
||||
const prefix = tablePrefix(pluginId);
|
||||
const owned = new Set<string>();
|
||||
for (const sql of sqls) {
|
||||
for (const statement of splitStatements(sql)) {
|
||||
const tokens = tokenize(statement);
|
||||
const upper = (at: number) =>
|
||||
tokens[at]?.kind === "word" ? tokens[at].value.toUpperCase() : "";
|
||||
if (upper(0) !== "CREATE") continue;
|
||||
let at = 1;
|
||||
while (CREATE_MODIFIERS.includes(upper(at))) at++;
|
||||
if (upper(at) !== "INDEX") continue;
|
||||
at++;
|
||||
while (["IF", "NOT", "EXISTS"].includes(upper(at))) at++;
|
||||
const name = tokens[at];
|
||||
let on = at;
|
||||
while (on < tokens.length && upper(on) !== "ON") on++;
|
||||
if (upper(on + 1) === "ONLY") on++;
|
||||
const target = tokens[on + 1];
|
||||
if (
|
||||
name &&
|
||||
name.kind !== "punct" &&
|
||||
target &&
|
||||
tokens[on + 2]?.value !== "." &&
|
||||
(target.value.startsWith(prefix) || ownedLegacy.has(target.value))
|
||||
) {
|
||||
owned.add(name.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return owned;
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* The desktop app's bridge (window.electronAPI), typed for plugins. It exists
|
||||
* only inside the Termix desktop app; check isElectron before calling it.
|
||||
*/
|
||||
|
||||
export interface ServerConfig {
|
||||
serverUrl?: string;
|
||||
allowInvalidCertificate?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DialogOptions {
|
||||
title?: string;
|
||||
defaultPath?: string;
|
||||
buttonLabel?: string;
|
||||
filters?: Array<{ name: string; extensions: string[] }>;
|
||||
properties?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DialogResult {
|
||||
canceled: boolean;
|
||||
filePath?: string;
|
||||
filePaths?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LocalDirectoryResult {
|
||||
success: boolean;
|
||||
path: string;
|
||||
parent?: string;
|
||||
entries: LocalFileEntry[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LocalCollectedFile {
|
||||
path: string;
|
||||
name: string;
|
||||
relativePath: string;
|
||||
size: number;
|
||||
created?: string;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export type LocalPathMutationResult =
|
||||
| ({ success: true } & Partial<LocalFileEntry>)
|
||||
| { success: false; error?: string };
|
||||
|
||||
export interface ElectronAPI {
|
||||
getAppVersion: () => Promise<string>;
|
||||
setZoomFactor?: (factor: number) => void;
|
||||
getPlatform: () => Promise<string>;
|
||||
getSetting?: (key: string) => Promise<string | null | undefined>;
|
||||
setSetting?: (key: string, value: string) => Promise<void>;
|
||||
|
||||
getServerConfig: () => Promise<ServerConfig>;
|
||||
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
|
||||
testServerConnection: (
|
||||
serverUrl: string,
|
||||
allowInvalidCertificate?: boolean,
|
||||
) => Promise<ConnectionTestResult>;
|
||||
getC2STunnelConfig: () => Promise<unknown[]>;
|
||||
saveC2STunnelConfig: (
|
||||
config: unknown[],
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
checkLocalPortAvailable: (
|
||||
host: string,
|
||||
port: number,
|
||||
) => Promise<{ available: boolean; error?: string }>;
|
||||
getC2STunnelPresetDefaultName: () => Promise<string>;
|
||||
startC2STunnel: (
|
||||
tunnel: unknown,
|
||||
index: number,
|
||||
authToken?: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
tunnelName?: string;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}>;
|
||||
testC2STunnel: (
|
||||
tunnel: unknown,
|
||||
index: number,
|
||||
authToken?: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}>;
|
||||
stopC2STunnel: (
|
||||
tunnelName: string,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
|
||||
onC2STunnelStatuses?: (
|
||||
callback: (statuses: Record<string, unknown>) => void,
|
||||
) => () => void;
|
||||
startC2SAutoStartTunnels: () => Promise<{
|
||||
success: boolean;
|
||||
started: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
/** Where the linked server relays client tunnel streams, a /plugin-ws/ path. */
|
||||
setC2SRelayPath?: (
|
||||
relayPath: string,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
onCloseActiveTab?: (callback: () => void) => () => void;
|
||||
clearSessionCookies: () => Promise<void>;
|
||||
getSessionCookie: (
|
||||
name: string,
|
||||
targetUrl?: string,
|
||||
) => Promise<string | null>;
|
||||
waitForSessionCookie: (
|
||||
name: string,
|
||||
targetUrl?: string,
|
||||
previousValue?: string | null,
|
||||
timeoutMs?: number,
|
||||
) => Promise<{ success: boolean; value?: string; error?: string }>;
|
||||
|
||||
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||
getLocalHomeDirectory?: () => Promise<string>;
|
||||
listLocalDirectory?: (path?: string) => Promise<LocalDirectoryResult>;
|
||||
statLocalPaths?: (
|
||||
paths: string[],
|
||||
) => Promise<
|
||||
Array<
|
||||
| (LocalFileEntry & { success: true })
|
||||
| { success: false; path: string; error?: string }
|
||||
>
|
||||
>;
|
||||
collectLocalFiles?: (paths: string[]) => Promise<{
|
||||
success: boolean;
|
||||
files: LocalCollectedFile[];
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
createLocalFolder?: (
|
||||
parentPath: string,
|
||||
folderName: string,
|
||||
) => Promise<LocalPathMutationResult>;
|
||||
renameLocalPath?: (
|
||||
path: string,
|
||||
newName: string,
|
||||
) => Promise<LocalPathMutationResult>;
|
||||
trashLocalPath?: (path: string) => Promise<LocalPathMutationResult>;
|
||||
chmodLocalPath?: (
|
||||
path: string,
|
||||
permissions: string,
|
||||
) => Promise<LocalPathMutationResult>;
|
||||
|
||||
openExternalEditor: (fileData: {
|
||||
fileName: string;
|
||||
content: string;
|
||||
encoding?: "utf8" | "base64";
|
||||
editorPath?: string | null;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
editId?: string;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
closeExternalEditor: (editId: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
onExternalEditorSaved?: (
|
||||
callback: (payload: {
|
||||
editId: string;
|
||||
content: string;
|
||||
encoding: "utf8";
|
||||
path: string;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
|
||||
onUpdateAvailable: (callback: () => void) => void;
|
||||
onUpdateDownloaded: (callback: () => void) => void;
|
||||
|
||||
removeAllListeners: (channel: string) => void;
|
||||
isElectron: boolean;
|
||||
isDev: boolean;
|
||||
|
||||
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
createTempFile: (fileData: {
|
||||
fileName: string;
|
||||
content: string;
|
||||
encoding?: "base64" | "utf8";
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
tempId?: string;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
createTempFolder: (folderData: {
|
||||
folderName: string;
|
||||
files: Array<{
|
||||
relativePath: string;
|
||||
content: string;
|
||||
encoding?: "base64" | "utf8";
|
||||
}>;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
tempId?: string;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
startDragToDesktop: (dragData: {
|
||||
tempId: string;
|
||||
fileName: string;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
cleanupTempFile: (tempId: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
startLocalTerminal(dimensions: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
shell?: "default" | "wsl";
|
||||
}): Promise<{ sessionId: string; shell: string }>;
|
||||
readyLocalTerminal(sessionId: string): Promise<boolean>;
|
||||
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
|
||||
resizeLocalTerminal(
|
||||
sessionId: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
): Promise<boolean>;
|
||||
closeLocalTerminal(sessionId: string): Promise<boolean>;
|
||||
onLocalTerminalData(
|
||||
sessionId: string,
|
||||
callback: (data: string) => void,
|
||||
): () => void;
|
||||
onLocalTerminalExit(
|
||||
sessionId: string,
|
||||
callback: (exitCode: number) => void,
|
||||
): () => void;
|
||||
|
||||
/** Local disk browsing for the dual-pane file manager (desktop only). */
|
||||
localFs?: {
|
||||
home: () => Promise<LocalFsResult<LocalFsHomeInfo>>;
|
||||
list: (dirPath: string) => Promise<LocalFsResult<LocalDirectoryListing>>;
|
||||
mkdir: (
|
||||
parentPath: string,
|
||||
name: string,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
createFile: (
|
||||
parentPath: string,
|
||||
name: string,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
rename: (
|
||||
oldPath: string,
|
||||
newName: string,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
trash: (paths: string[]) => Promise<LocalFsResult<LocalTrashResult>>;
|
||||
ensureDir: (
|
||||
dirPath: string,
|
||||
rootPath?: string,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
exists: (paths: string[]) => Promise<LocalFsResult<{ existing: string[] }>>;
|
||||
walk: (paths: string[]) => Promise<LocalFsResult<LocalWalkResult>>;
|
||||
reveal: (targetPath: string) => Promise<LocalFsResult<unknown>>;
|
||||
open: (targetPath: string) => Promise<LocalFsResult<unknown>>;
|
||||
};
|
||||
|
||||
/** Streamed local<->remote transfers driven by the main process. */
|
||||
localTransfer?: {
|
||||
/** The /plugin-api/<id> path that serves the streaming routes. */
|
||||
setApiPath?: (apiPath: string) => Promise<LocalFsResult<object>>;
|
||||
upload: (
|
||||
options: LocalUploadRequest,
|
||||
) => Promise<LocalFsResult<{ bytes: number }>>;
|
||||
download: (
|
||||
options: LocalDownloadRequest,
|
||||
) => Promise<LocalFsResult<{ path: string }>>;
|
||||
cancel: (
|
||||
transferId: string,
|
||||
) => Promise<LocalFsResult<{ cancelled: boolean }>>;
|
||||
onProgress: (
|
||||
callback: (payload: LocalTransferProgress) => void,
|
||||
) => () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalFsResult<T> =
|
||||
({ success: true } & T) | { success: false; error: string; code?: string };
|
||||
|
||||
export interface LocalFsHomeInfo {
|
||||
home: string;
|
||||
separator: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface LocalFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory" | "link" | "other";
|
||||
created?: string;
|
||||
modified?: string;
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
size: number;
|
||||
modifiedTimestamp?: number;
|
||||
linkTarget?: string;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface LocalDirectoryListing {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: LocalFileEntry[];
|
||||
}
|
||||
|
||||
export interface LocalTrashResult {
|
||||
trashed: number;
|
||||
failed: Array<{ path: string; error: string }>;
|
||||
}
|
||||
|
||||
export interface LocalWalkFile {
|
||||
localPath: string;
|
||||
/** Path relative to the drop root; "/"-separated and includes the root's own name. */
|
||||
relativePath: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LocalWalkResult {
|
||||
files: LocalWalkFile[];
|
||||
emptyDirs: string[];
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which Termix backend a transfer talks to. The main process resolves the
|
||||
* actual URL and credentials for the origin; the renderer never supplies them.
|
||||
*/
|
||||
export type LocalTransferOrigin = "local" | "remote";
|
||||
|
||||
export interface LocalUploadRequest {
|
||||
transferId: string;
|
||||
origin: LocalTransferOrigin;
|
||||
fields: Record<string, string>;
|
||||
localPath: string;
|
||||
fileName: string;
|
||||
deviceId?: string;
|
||||
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface LocalDownloadRequest {
|
||||
transferId: string;
|
||||
origin: LocalTransferOrigin;
|
||||
body: Record<string, unknown>;
|
||||
destPath: string;
|
||||
/** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */
|
||||
rootPath: string;
|
||||
expectedSize?: number;
|
||||
/** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */
|
||||
overwrite?: boolean;
|
||||
deviceId?: string;
|
||||
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface LocalTransferProgress {
|
||||
transferId: string;
|
||||
transferred: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
IS_ELECTRON: boolean;
|
||||
electronClipboard?: {
|
||||
writeText(text: string): Promise<boolean>;
|
||||
readText(): Promise<string>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -155,9 +155,6 @@ export interface PluginTabRecord {
|
||||
instanceId?: string;
|
||||
/** A backend session a session tab should reattach to. */
|
||||
restoredSessionId?: string | null;
|
||||
/** A live shared session this tab joins instead of connecting its own. */
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -192,6 +189,11 @@ export interface ShellApi {
|
||||
options?: OpenTabOptions,
|
||||
) => void;
|
||||
openSingletonTab: (type: string, options?: OpenTabOptions) => void;
|
||||
/**
|
||||
* Connects to a host the way clicking it in the host list does: its
|
||||
* default connection, or `type` when given.
|
||||
*/
|
||||
connectHost: (host: PluginHostRecord, type?: string) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
renameTab: (tabId: string, label: string) => void;
|
||||
/** Saves a quick-connect tab's host as a real host. Absent in some shells. */
|
||||
@@ -212,7 +214,7 @@ export interface ShellApi {
|
||||
|
||||
export interface TabsApi extends Pick<
|
||||
ShellApi,
|
||||
"openTab" | "openSingletonTab" | "closeTab" | "openRailView"
|
||||
"openTab" | "openSingletonTab" | "connectHost" | "closeTab" | "openRailView"
|
||||
> {
|
||||
/** The current tabs and split layout, or null before the shell mounts. */
|
||||
getLayout: () => ShellLayout | null;
|
||||
@@ -1085,6 +1087,15 @@ export interface PluginCoreApi {
|
||||
listHosts: (pluginId: string) => Promise<PluginHostRecord[]>;
|
||||
/** The user's stored credentials, without their secrets. */
|
||||
listCredentials: () => Promise<PluginCredentialSummary[]>;
|
||||
/** Tells the shell hosts were added or changed, so lists reload. */
|
||||
notifyHostsChanged: () => void;
|
||||
/** How the user wants host status shown: the brand accent, or green/red. */
|
||||
getHostStatusColorScheme: () => "accent" | "status";
|
||||
/**
|
||||
* The token the desktop app's embedded backend accepts, for work the
|
||||
* desktop main process does on the renderer's behalf. Null in a browser.
|
||||
*/
|
||||
getLocalAuthToken: () => string | null;
|
||||
}
|
||||
|
||||
/** A stored credential as a picker needs it. Secrets never leave core. */
|
||||
@@ -1114,8 +1125,17 @@ export interface HostStatusInfo {
|
||||
|
||||
let host: PluginHostBridge | null = null;
|
||||
|
||||
/** Called once by core. Not part of the plugin API. */
|
||||
/**
|
||||
* Called once by core, before any plugin loads. Not part of the plugin API:
|
||||
* once set it cannot be swapped, so a plugin cannot replace the bridge every
|
||||
* other plugin talks through.
|
||||
*/
|
||||
export function __setPluginHost(bridge: PluginHostBridge | null): void {
|
||||
if (host && bridge !== host) {
|
||||
throw new Error(
|
||||
"@termix/plugin-sdk/frontend: the plugin host is already set",
|
||||
);
|
||||
}
|
||||
host = bridge;
|
||||
}
|
||||
|
||||
@@ -1211,6 +1231,21 @@ export function logActivity(
|
||||
return requireHost().core.logActivity(type, hostId, hostName);
|
||||
}
|
||||
|
||||
/** Tells the shell hosts were added or changed, so its lists reload. */
|
||||
export function notifyHostsChanged(): void {
|
||||
requireHost().core.notifyHostsChanged();
|
||||
}
|
||||
|
||||
/** How the user wants host status shown: the brand accent, or green/red. */
|
||||
export function getHostStatusColorScheme(): "accent" | "status" {
|
||||
return requireHost().core.getHostStatusColorScheme();
|
||||
}
|
||||
|
||||
/** The desktop app's local backend token, or null in a browser. */
|
||||
export function getLocalAuthToken(): string | null {
|
||||
return requireHost().core.getLocalAuthToken();
|
||||
}
|
||||
|
||||
export function getHostPassword(
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword",
|
||||
@@ -1583,3 +1618,6 @@ export function useConnectionRetry({
|
||||
|
||||
export type { PluginManifest } from "./manifest.js";
|
||||
export type { ReactNode };
|
||||
|
||||
// The desktop bridge types, and the window.electronAPI global they declare.
|
||||
export type * from "./desktop.js";
|
||||
@@ -14,10 +14,40 @@ import semver from "semver";
|
||||
import { isKnownCapability } from "./capabilities.js";
|
||||
|
||||
/**
|
||||
* The SDK major version this build implements. engine.api is the real
|
||||
* compatibility gate; engine.termix is a display string and is never enforced.
|
||||
* The plugin API this build implements. A minor bump adds to the API and
|
||||
* never removes; a major bump may break plugins.
|
||||
*
|
||||
* engine.api is a semver range checked against this ("1" means any 1.x,
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = "1";
|
||||
export const PLUGIN_API_VERSION = "1.0.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
semver.major(PLUGIN_API_VERSION),
|
||||
);
|
||||
|
||||
/** Whether this build's plugin API satisfies a manifest's engine.api. */
|
||||
export function isApiCompatible(range: string): boolean {
|
||||
return (
|
||||
semver.validRange(range) !== null &&
|
||||
semver.satisfies(PLUGIN_API_VERSION, range)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a core version satisfies a manifest's engine.termix. A prerelease
|
||||
* core (2.9.0-beta.1) counts as that release.
|
||||
*/
|
||||
export function isTermixCompatible(
|
||||
range: string,
|
||||
coreVersion: string | null,
|
||||
): boolean {
|
||||
if (!coreVersion || !semver.valid(semver.coerce(coreVersion))) return true;
|
||||
if (semver.validRange(range) === null) return false;
|
||||
return semver.satisfies(semver.coerce(coreVersion)!.version, range);
|
||||
}
|
||||
|
||||
export const PLUGIN_CATEGORIES = [
|
||||
"Terminal",
|
||||
@@ -59,14 +89,12 @@ export const RESERVED_PLUGIN_IDS: readonly string[] = [
|
||||
/** A preset key is a plain property name, never a prototype one. */
|
||||
const PRESET_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
|
||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
||||
const API_VERSION_PATTERN = /^[0-9]+$/;
|
||||
/** Service names are dotted, e.g. "ssh.transport". */
|
||||
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
||||
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
|
||||
/** Action ids name a frontend function, so segments may be camelCase. */
|
||||
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
|
||||
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/;
|
||||
/**
|
||||
* A plugin-relative permission name. Unlike a full id one segment is fine,
|
||||
* and underscores are allowed in the first segment too ("manage_providers").
|
||||
@@ -98,9 +126,12 @@ export interface PluginAuthor {
|
||||
}
|
||||
|
||||
export interface PluginEngine {
|
||||
/** Display string only, e.g. ">=2.9.0". Never enforced. */
|
||||
/** Semver range of Termix releases the plugin runs on, e.g. ">=2.9.0". */
|
||||
termix: string;
|
||||
/** SDK major version as a string. The real gate. */
|
||||
/**
|
||||
* Semver range of the plugin API it needs, e.g. "1" or "^1.2". Use the
|
||||
* lowest minor whose API you call, so older cores refuse it cleanly.
|
||||
*/
|
||||
api: string;
|
||||
}
|
||||
|
||||
@@ -257,6 +288,11 @@ export interface PluginSettingsField {
|
||||
shareRead?: "connect" | "view" | "edit" | "manage";
|
||||
/** Host scope only: only the host's owner may change it. */
|
||||
ownerOnly?: boolean;
|
||||
/**
|
||||
* JSON fields only: keys inside the stored object that hold secrets, such
|
||||
* as a gateway password. Core clears them from exports like any secret.
|
||||
*/
|
||||
secretKeys?: string[];
|
||||
/**
|
||||
* Stored and validated like any field, but not drawn by the generic
|
||||
* settings form, because the plugin edits it in its own UI (a host editor
|
||||
@@ -344,6 +380,13 @@ export interface PluginContributions {
|
||||
* off and a desktop can list them in its sync settings.
|
||||
*/
|
||||
syncEntities?: string[];
|
||||
/**
|
||||
* Other plugins' action, slot and extension point ids this plugin calls,
|
||||
* fills or extends. Declaration only: nothing breaks when the owner is
|
||||
* missing, but the coupling is written down where tools and people can
|
||||
* see it.
|
||||
*/
|
||||
uses?: string[];
|
||||
/**
|
||||
* Keybinding actions this plugin adds to Appearance > Keybindings, with
|
||||
* the parameters a saved binding carries. Core validates saved bindings
|
||||
@@ -562,6 +605,7 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"uiPresets",
|
||||
"auth",
|
||||
"syncEntities",
|
||||
"uses",
|
||||
"keybindingActions",
|
||||
"protocols",
|
||||
]);
|
||||
@@ -584,6 +628,7 @@ const ALLOWED_SETTINGS_FIELD = [
|
||||
"defaultFrom",
|
||||
"shareRead",
|
||||
"ownerOnly",
|
||||
"secretKeys",
|
||||
];
|
||||
|
||||
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
|
||||
@@ -738,9 +783,20 @@ function validateEngine(engine: unknown, errors: string[]): void {
|
||||
}
|
||||
rejectUnknown(engine, ["termix", "api"], '"engine"', errors);
|
||||
requireString(engine.termix, "engine.termix", errors);
|
||||
if (typeof engine.api !== "string" || !API_VERSION_PATTERN.test(engine.api)) {
|
||||
if (
|
||||
typeof engine.termix === "string" &&
|
||||
semver.validRange(engine.termix) === null
|
||||
) {
|
||||
errors.push(
|
||||
`Field "engine.api" must be an integer string, got: ${JSON.stringify(engine.api)}`,
|
||||
`Field "engine.termix" must be a semver range, got: ${JSON.stringify(engine.termix)}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof engine.api !== "string" ||
|
||||
semver.validRange(engine.api) === null
|
||||
) {
|
||||
errors.push(
|
||||
`Field "engine.api" must be a semver range such as "1" or "^1.2", got: ${JSON.stringify(engine.api)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -895,6 +951,28 @@ function validateSyncEntities(value: unknown, errors: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateUses(value: unknown, errors: string[]) {
|
||||
const where = "contributes.uses";
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
errors.push(`${where} must be a non-empty array`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const id of value) {
|
||||
if (
|
||||
typeof id !== "string" ||
|
||||
!/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(id)
|
||||
) {
|
||||
errors.push(
|
||||
`${where} entries must be dotted ids such as "terminal.open"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(id)) errors.push(`${where} duplicates "${id}"`);
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
function validateNameList(value: unknown, where: string, errors: string[]) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
errors.push(`${where} must be a non-empty array`);
|
||||
@@ -1025,6 +1103,9 @@ function validateContributes(
|
||||
if (contributes.syncEntities !== undefined) {
|
||||
validateSyncEntities(contributes.syncEntities, errors);
|
||||
}
|
||||
if (contributes.uses !== undefined) {
|
||||
validateUses(contributes.uses, errors);
|
||||
}
|
||||
validatePermissions(contributes.permissions, pluginId, errors);
|
||||
validateActions(contributes.actions, errors);
|
||||
validateActionSlots(contributes.actionSlots, errors);
|
||||
@@ -1534,6 +1615,18 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") {
|
||||
errors.push(`${fieldAt}.ownerOnly must be a boolean`);
|
||||
}
|
||||
if ("secretKeys" in raw) {
|
||||
const keys = raw.secretKeys;
|
||||
if (raw.type !== "json") {
|
||||
errors.push(`${fieldAt}.secretKeys is only valid on json fields`);
|
||||
} else if (
|
||||
!Array.isArray(keys) ||
|
||||
keys.length === 0 ||
|
||||
keys.some((key) => typeof key !== "string" || key.length === 0)
|
||||
) {
|
||||
errors.push(`${fieldAt}.secretKeys must be a non-empty string array`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const scope of ["admin", "user"] as const) {
|
||||
@@ -1957,10 +2050,10 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
||||
|
||||
const manifest = raw as PluginManifest;
|
||||
|
||||
if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) {
|
||||
if (!isApiCompatible(manifest.engine.api)) {
|
||||
return {
|
||||
errors: [
|
||||
`Plugin targets SDK API version ${manifest.engine.api}, but this build implements ${SUPPORTED_PLUGIN_API_VERSION}`,
|
||||
`Plugin needs plugin API ${manifest.engine.api}, but this build implements ${PLUGIN_API_VERSION}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ import type {
|
||||
import type { SyncEntityRegistration } from "./backend.js";
|
||||
import type { PluginDatabase } from "./backend.js";
|
||||
import type {
|
||||
PluginNativeRdpRequest,
|
||||
PluginExternalClientRequest,
|
||||
PluginProtocolTarget,
|
||||
PluginHostStatusEntry,
|
||||
PluginNotification,
|
||||
@@ -297,8 +297,8 @@ export interface FakePluginContext {
|
||||
title?: string;
|
||||
ignoreCert?: boolean;
|
||||
}>;
|
||||
/** Every ctx.desktop.launchNativeRdp call, in order. */
|
||||
nativeRdpLaunches: PluginNativeRdpRequest[];
|
||||
/** Every ctx.desktop.launchExternalClient call, in order. */
|
||||
externalClientLaunches: PluginExternalClientRequest[];
|
||||
/** Every ctx.credentials.resolveHostProtocol call, in order. */
|
||||
credentialReads: Array<{ hostId: number; protocol: string }>;
|
||||
/** Every ctx.credentials.createSshKey call, with the id it answered. */
|
||||
@@ -361,6 +361,11 @@ export interface FakePluginContext {
|
||||
runScheduled: () => Promise<void>;
|
||||
/** Changes the acting user, as core's request middleware would. */
|
||||
setActor: (userId: string | undefined) => void;
|
||||
/**
|
||||
* Runs `fn` as `userId`, the way a request from that user would. For test
|
||||
* code standing in for a caller; unlike ctx.asUser it needs no capability.
|
||||
*/
|
||||
actAs: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
|
||||
/** Provided or seeded services, by name or "<service>#<provider>". */
|
||||
services: Map<string, object>;
|
||||
}
|
||||
@@ -497,7 +502,8 @@ export function createFakeContext(
|
||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||
const hostShares: FakePluginContext["hostShares"] = [];
|
||||
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
|
||||
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
|
||||
[];
|
||||
const credentialReads: FakePluginContext["credentialReads"] = [];
|
||||
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
|
||||
const notifications: FakePluginContext["notifications"] = [];
|
||||
@@ -695,10 +701,12 @@ export function createFakeContext(
|
||||
|
||||
registry: {
|
||||
provide: (key, value) => {
|
||||
assertOwnRegistryKey(pluginId, key);
|
||||
registryProviders.set(key, value);
|
||||
},
|
||||
consume: (key) => registryProviders.get(key) as never,
|
||||
revoke: (key, value) => {
|
||||
assertOwnRegistryKey(pluginId, key);
|
||||
if (!registryProviders.has(key)) return false;
|
||||
if (value !== undefined && registryProviders.get(key) !== value) {
|
||||
return false;
|
||||
@@ -1128,8 +1136,8 @@ export function createFakeContext(
|
||||
desktopWindows.push(request);
|
||||
return { success: true };
|
||||
},
|
||||
launchNativeRdp: async (request) => {
|
||||
nativeRdpLaunches.push(request);
|
||||
launchExternalClient: async (request) => {
|
||||
externalClientLaunches.push(request);
|
||||
return { success: true };
|
||||
},
|
||||
available: () => options.desktopAvailable ?? false,
|
||||
@@ -1309,7 +1317,7 @@ export function createFakeContext(
|
||||
hostShares,
|
||||
auth,
|
||||
desktopWindows,
|
||||
nativeRdpLaunches,
|
||||
externalClientLaunches,
|
||||
credentialReads,
|
||||
createdSshKeys,
|
||||
notifications,
|
||||
@@ -1337,6 +1345,15 @@ export function createFakeContext(
|
||||
setActor: (userId) => {
|
||||
actor = userId;
|
||||
},
|
||||
actAs: async (userId, fn) => {
|
||||
const previous = actor;
|
||||
actor = userId;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
actor = previous;
|
||||
}
|
||||
},
|
||||
services,
|
||||
};
|
||||
}
|
||||
@@ -1477,6 +1494,11 @@ export function createMockCtx(
|
||||
const gatedCtx: PluginContext = {
|
||||
...ctx,
|
||||
|
||||
asUser: async (userId, fn) => {
|
||||
require("users:impersonate");
|
||||
return ctx.asUser(userId, fn);
|
||||
},
|
||||
|
||||
db: {
|
||||
define: async (definition) => {
|
||||
require("db:own");
|
||||
@@ -1487,7 +1509,7 @@ export function createMockCtx(
|
||||
return guardedDb.client();
|
||||
},
|
||||
refs: async () => {
|
||||
require("db:own");
|
||||
require("db:core-refs");
|
||||
return guardedDb.refs();
|
||||
},
|
||||
persist: async (persistOptions) => {
|
||||
@@ -1745,9 +1767,9 @@ export function createMockCtx(
|
||||
require("desktop:window");
|
||||
return ctx.desktop.openIsolatedWindow(request);
|
||||
},
|
||||
launchNativeRdp: async (request) => {
|
||||
launchExternalClient: async (request) => {
|
||||
require("desktop:window");
|
||||
return ctx.desktop.launchNativeRdp(request);
|
||||
return ctx.desktop.launchExternalClient(request);
|
||||
},
|
||||
available: () => ctx.desktop.available(),
|
||||
},
|
||||
@@ -1882,7 +1904,7 @@ export interface TestDbOptions {
|
||||
* Runs after the core stub tables exist and before the plugin's migrations,
|
||||
* e.g. to create the legacy table an adoption migration renames.
|
||||
*/
|
||||
before?: (sqlite: TestSqlite) => void | Promise<void>;
|
||||
before?: (sqlite: TestSqlite) => unknown;
|
||||
/** Skip applying the migrations, to apply them by hand later. */
|
||||
skipMigrations?: boolean;
|
||||
}
|
||||
@@ -2046,6 +2068,8 @@ export interface RenderWithAppOptions {
|
||||
ready?: boolean;
|
||||
/** Stands in for app.api and usePluginApi(), e.g. a stub of the routes. */
|
||||
api?: import("./frontend.js").PluginApiClient;
|
||||
/** What app.desktop.remoteServerUrl() answers. Read on every call. */
|
||||
remoteServerUrl?: () => string | null | Promise<string | null>;
|
||||
}
|
||||
|
||||
/** A call a plugin made on the shell, recorded instead of performed. */
|
||||
@@ -2150,3 +2174,11 @@ export async function renderWithApp(
|
||||
const host = (await import(/* @vite-ignore */ TEST_HOST)) as PluginTestHost;
|
||||
return host.renderPlugin(plugin, options);
|
||||
}
|
||||
|
||||
function assertOwnRegistryKey(pluginId: string, key: string): void {
|
||||
if (typeof key !== "string" || !key.startsWith(`${pluginId}.`)) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} may only use registry keys under "${pluginId}.", not "${key}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ const offline = () =>
|
||||
useTabs: () => ({
|
||||
openTab: noop,
|
||||
openSingletonTab: noop,
|
||||
connectHost: noop,
|
||||
closeTab: noop,
|
||||
getLayout: () => null,
|
||||
applyLayout: async () => ({ skipped: [] }),
|
||||
@@ -142,6 +143,9 @@ const offline = () =>
|
||||
setClientPreference: () => {},
|
||||
listHosts: async () => [],
|
||||
listCredentials: async () => [],
|
||||
notifyHostsChanged: () => {},
|
||||
getHostStatusColorScheme: () => "accent",
|
||||
getLocalAuthToken: () => null,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -14,12 +14,11 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Walks up from a plugin directory to the repo that holds src/ui.
|
||||
* Walks up from a plugin directory to a Termix checkout, if it is in one.
|
||||
*
|
||||
* Plugin frontends and their tests still reach core through the "@/" alias
|
||||
* (the legacy debt A7 and D1 remove), so the preset has to resolve it. A
|
||||
* plugin developed outside this repo has no src/ui to point at, which is
|
||||
* exactly right: the alias stops resolving and the import fails loudly.
|
||||
* Inside the repo the SDK's browser entries and the test host resolve to
|
||||
* core's source, so a test exercises the real registries. Outside it they
|
||||
* resolve to the builds the SDK ships in dist/host.
|
||||
*/
|
||||
function findRepoRoot(startDir: string): string | null {
|
||||
let dir = startDir;
|
||||
@@ -83,7 +82,13 @@ export function pluginVitestConfig(
|
||||
"@/types": path.join(repoRoot, "src", "types"),
|
||||
"@": path.join(repoRoot, "src", "ui"),
|
||||
}
|
||||
: {}),
|
||||
: {
|
||||
// Outside a Termix checkout: the UI kit resolves through the
|
||||
// package, and the test host is the copy the SDK ships.
|
||||
"@termix/plugin-host/testing": fileURLToPath(
|
||||
new URL("./host/testing-host.js", import.meta.url),
|
||||
),
|
||||
}),
|
||||
...options.alias,
|
||||
};
|
||||
|
||||
@@ -112,6 +117,10 @@ export function pluginVitestConfig(
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
// Run the SDK through Vite rather than plain Node, so its import
|
||||
// of the test host sees the alias above and the plugin and the
|
||||
// host share one copy of @termix/plugin-sdk/frontend.
|
||||
server: { deps: { inline: [/@termix\/plugin-sdk/] } },
|
||||
include: ["tests/frontend/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["node", "vitest/globals"],
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
@@ -24,5 +26,6 @@
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"allowUnusedLabels": true,
|
||||
"allowUnreachableCode": true
|
||||
}
|
||||
},
|
||||
"files": ["./client.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
|
||||
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
|
||||
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
|
||||
AlertDialogAction (value): ({ className, ...props }: AlertDialogActionProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
AlertDialogCancel (value): ({ className, ...props }: AlertDialogCancelProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
AlertDialogContent (value): ({ className, ...props }: AlertDialogContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
AlertDialogDescription (value): ({ className, ...props }: AlertDialogDescriptionProps & RefAttributes<HTMLParagraphElement>) => Element
|
||||
AlertDialogFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
AlertDialogHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
AlertDialogOverlay (value): ({ className, ...props }: AlertDialogOverlayProps & RefAttributes<HTMLDivElement>) => Element
|
||||
AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
|
||||
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
|
||||
BarSeriesItem (type): BarSeriesItem
|
||||
BarSeriesProps (type): BarSeriesProps
|
||||
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
|
||||
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||
ButtonProps (type): ButtonProps
|
||||
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
|
||||
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
CardDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
CardFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
CardGridCanvas (value): ({ slots, columns, editMode, renderCard, cardCatalog, onChange, cardLabel, className, }: CardGridCanvasProps) => Element
|
||||
CardHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
|
||||
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
|
||||
ConnectionLogPanel (value): ({ isConnecting, isConnected, hasConnectionError, position, className, }: ConnectionLogPanelProps) => Element
|
||||
ConnectionLogProvider (value): ({ children, }: { children: ReactNode; }) => Element
|
||||
ConnectionOrigin (type): ConnectionOrigin
|
||||
ConnectionScreen (value): ({ status, message, backgroundColor, attempt, maxAttempts, nextRetryInMs, onManualRetry, retryLabel, disconnectedMessage, extraActions, logPosition, emptyState, className, }: ConnectionScreenProps) => Element
|
||||
ConnectionStage (type): ConnectionStage
|
||||
ConnectionStatus (type): ConnectionStatus
|
||||
CustomKeybinding (type): CustomKeybinding
|
||||
Dialog (value): ({ ...props }: DialogProps) => Element
|
||||
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
|
||||
DialogDescription (value): ({ className, ...props }: DialogDescriptionProps & RefAttributes<HTMLParagraphElement>) => Element
|
||||
DialogFooter (value): ({ className, showCloseButton, children, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
|
||||
DialogHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
DialogOverlay (value): ({ className, ...props }: DialogOverlayProps & RefAttributes<HTMLDivElement>) => Element
|
||||
DialogPortal (value): ({ ...props }: DialogPortalProps) => Element
|
||||
DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
|
||||
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
||||
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
DropdownMenuGroup (value): ({ ...props }: DropdownMenuGroupProps & RefAttributes<HTMLDivElement>) => Element
|
||||
DropdownMenuItem (value): ({ className, inset, variant, ...props }: DropdownMenuItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; variant?: "default" | "destructive"; }) => Element
|
||||
DropdownMenuLabel (value): ({ className, inset, ...props }: DropdownMenuLabelProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
||||
DropdownMenuPortal (value): ({ ...props }: DropdownMenuPortalProps) => Element
|
||||
DropdownMenuRadioGroup (value): ({ ...props }: DropdownMenuRadioGroupProps & RefAttributes<HTMLDivElement>) => Element
|
||||
DropdownMenuRadioItem (value): ({ className, children, inset, ...props }: DropdownMenuRadioItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
||||
DropdownMenuSeparator (value): ({ className, ...props }: DropdownMenuSeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
||||
DropdownMenuShortcut (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>) => Element
|
||||
DropdownMenuSub (value): ({ ...props }: DropdownMenuSubProps) => Element
|
||||
DropdownMenuSubContent (value): ({ className, sideOffset, alignOffset, ...props }: DropdownMenuSubContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: DropdownMenuSubTriggerProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
||||
DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
|
||||
FOLDER_COLORS (value): string[]
|
||||
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
|
||||
FullScreenAppPhase (type): FullScreenAppPhase
|
||||
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
||||
GridCardCatalogEntry (type): GridCardCatalogEntry
|
||||
GridColSpan (type): GridColSpan
|
||||
GridLayout (type): GridLayout
|
||||
GridSlot (type): GridSlot
|
||||
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
||||
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
||||
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
||||
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
||||
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
||||
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
||||
KeyCombo (type): KeyCombo
|
||||
KeybindingAction (type): KeybindingAction
|
||||
KeybindingActionType (type): string
|
||||
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||
LineChartSeries (type): LineChartSeries
|
||||
LogContext (type): LogContext
|
||||
LogEntry (type): LogEntry
|
||||
MFAPromptMode (type): MFAPromptMode
|
||||
ManagerCardError (type): ManagerCardError
|
||||
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
|
||||
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
|
||||
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
|
||||
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
|
||||
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
||||
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
|
||||
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
|
||||
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
|
||||
Popover (value): ({ ...props }: PopoverProps) => Element
|
||||
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
|
||||
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
|
||||
RadialGaugeProps (type): RadialGaugeProps
|
||||
RecentActivityItem (type): RecentActivityItem
|
||||
RobustClipboardProvider (value): typeof RobustClipboardProvider
|
||||
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
|
||||
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
||||
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SectionCard (value): ({ title, icon, action, children, className, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; }) => Element
|
||||
Select (value): ({ ...props }: SelectSharedProps & { value?: string; defaultValue?: string; onValueChange?(value: string): void; }) => Element
|
||||
Select2 (value): ({ className, children, value, defaultValue, onChange, disabled, placeholder, align, contentClassName, name, id, required, "aria-label": ariaLabel, ...props }: Select2Props) => Element
|
||||
SelectContent (value): ({ className, children, position, ...props }: SelectContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SelectGroup (value): ({ ...props }: SelectGroupProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SelectItem (value): ({ className, children, ...props }: SelectItemProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SelectLabel (value): ({ className, ...props }: SelectLabelProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SelectScrollDownButton (value): ({ className, ...props }: SelectScrollDownButtonProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SelectScrollUpButton (value): ({ className, ...props }: SelectScrollUpButtonProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SelectSeparator (value): ({ className, ...props }: SelectSeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerProps & RefAttributes<HTMLButtonElement> & { size?: "default" | "sm"; }) => Element
|
||||
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
||||
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SettingRow (value): ({ label, badge, description, children, }: { label: string; badge?: string; description?: ReactNode; children: ReactNode; }) => Element
|
||||
Sheet (value): ({ ...props }: DialogProps) => Element
|
||||
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
|
||||
SheetDescription (value): ({ className, ...props }: DialogDescriptionProps & RefAttributes<HTMLParagraphElement>) => Element
|
||||
SheetFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
|
||||
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
|
||||
SparklineProps (type): SparklineProps
|
||||
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
|
||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||
TextareaProps (type): TextareaProps
|
||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
|
||||
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
UptimeInfo (type): UptimeInfo
|
||||
VersionInfo (type): VersionInfo
|
||||
WebSocketConnectionTarget (type): WebSocketConnectionTarget
|
||||
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
|
||||
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
|
||||
cn (value): (...inputs: ClassValue[]) => string
|
||||
copyToClipboard (value): (text: string) => Promise<boolean>
|
||||
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
||||
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
||||
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
|
||||
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
|
||||
getBasePath (value): () => string
|
||||
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
|
||||
getDeviceId (value): () => string
|
||||
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
|
||||
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
|
||||
getUptime (value): () => Promise<UptimeInfo>
|
||||
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
|
||||
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
|
||||
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
||||
isElectron (value): () => boolean
|
||||
isMacPlatform (value): () => boolean
|
||||
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
|
||||
linkedServerUrl (value): () => Promise<string>
|
||||
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
|
||||
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
|
||||
readFromClipboard (value): () => Promise<string>
|
||||
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
|
||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
|
||||
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
|
||||
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
|
||||
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
|
||||
useAppTheme (value): () => ThemeProviderState
|
||||
useConfirmation (value): () => { isOpen: boolean; options: ConfirmationOptions; confirm: (opts: ConfirmationOptions, callback: () => void) => void; handleConfirm: () => void; handleCancel: () => void; confirmWithToast: (opts: string | ConfirmationOptions, callback?: () => void, variantOrConfirmLabel?: string, cancelLabel?: string, toastOptions?: ToastConfirmOptions) => Promise<boolean>; }
|
||||
useConnectionLog (value): () => ConnectionLogContextType
|
||||
useIsMobile (value): () => boolean
|
||||
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
||||
useTabs (value): () => TabContextType
|
||||
useTabsSafe (value): () => TabContextType
|
||||
Reference in new issue
Block a user