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
@@ -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}"],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user