chore: lock the plugin boundary and remove 2.9.0 leftovers

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

No files matched your search

+20 -13
View File
@@ -737,19 +737,17 @@ export interface PluginSshHost {
}
/** Picks keepalive and timeout defaults. "plugin" is the generic one. */
export type PluginSshPurpose =
| "plugin"
| "docker"
| "docker-console"
| "metrics"
| "proxmox"
| "fleet"
| "remote-desktop"
| "tunnel"
| "file-manager"
| "file-transfer"
| "terminal"
| "tmux";
/** What a connection is for, as a free label. Auth providers see it. */
export type PluginSshPurpose = string;
/**
* Keepalive, timeout and environment defaults for a kind of connection.
* terminal: an interactive shell. session: a long-lived browsing session.
* stream: a long transfer or console. forward: port forwarding. background
* (the default): short exec work such as polling.
*/
export type PluginSshProfile =
"terminal" | "session" | "stream" | "forward" | "background";
export type PluginSshAuthOutcome =
| { status: "ready" }
@@ -868,6 +866,8 @@ export interface PluginSshPromptChannel {
export interface PluginSshConnectOptions {
purpose?: PluginSshPurpose;
/** Keepalive and timeout defaults. Defaults to "background". */
profile?: PluginSshProfile;
/** Overall time to reach "ready". Defaults to 30s. */
timeoutMs?: number;
/** Without one, the stored password answers password prompts. */
@@ -956,6 +956,8 @@ export interface PluginSsh {
host: PluginSshHost,
options: {
purpose?: PluginSshPurpose;
/** Keepalive and timeout defaults. Defaults to "background". */
profile?: PluginSshProfile;
client: unknown;
/** Server-side host id when it differs from host.id. */
serverHostId?: number;
@@ -1079,6 +1081,11 @@ export interface PluginSshAuthProvider {
requiresSecret?: boolean;
/** Can connect unattended. Defaults to true. */
supportsBackground?: boolean;
/**
* Works for a host that was never saved (nothing in host settings, no host
* id to key a cache on), so Quick Connect offers it. Default false.
*/
quickConnect?: boolean;
/** Interaction name its outcomes use, for startInteraction routing. */
interaction?: string;
connectOptions?: (
+32
View File
@@ -128,6 +128,11 @@ export interface RailItemContribution {
kind?: "panel" | "tab";
/** Users may hide it from Appearance > Sidebar > Navigation. Default true. */
hideable?: boolean;
/**
* Stays visible in the Simple interface preset, which hides every other
* plugin rail item. For the one or two things a new user reaches for.
*/
simplePreset?: boolean;
/** Can also open as a full-width tab. */
promotable?: boolean;
/** Can open in the right dock. */
@@ -302,6 +307,11 @@ export interface HostActionContribution {
copyUrlView?: string;
/** Where a host overview (the dashboard's host status list) sends a click. */
overview?: boolean;
/**
* Offered as a button in Quick Connect, next to the default connect button,
* for an address that is never saved. Opens tabType.
*/
quickConnect?: boolean;
/**
* Position in the host row. Core: Files 20, Tunnel 40, Tmux 70. Connect
* actions default to 100 and sit after a separator.
@@ -709,6 +719,8 @@ export interface SshAuthTypeInfo {
credentialType: boolean;
/** Can connect unattended, for polling. */
supportsBackground: boolean;
/** Offered in Quick Connect, for a host that is never saved. */
quickConnect: boolean;
}
/**
@@ -825,6 +837,18 @@ export interface PluginCoreApi {
setHostAutoTmux: (hostId: number, autoTmux: boolean) => Promise<void>;
/** A browser-side UI preference (a cookie, or the desktop app's store). */
getClientPreference: (name: string) => string | undefined;
/** Every host the user can see, fetched now rather than from the shell's cache. */
listHosts: () => Promise<PluginHostRecord[]>;
/** The user's stored credentials, without their secrets. */
listCredentials: () => Promise<PluginCredentialSummary[]>;
}
/** A stored credential as a picker needs it. Secrets never leave core. */
export interface PluginCredentialSummary {
id: number;
name: string;
username?: string;
authType?: string;
}
/** What a recent-activity entry opens and how it is labelled. */
@@ -972,6 +996,14 @@ export function getClientPreference(name: string): string | undefined {
return requireHost().core.getClientPreference(name);
}
export function listHosts(): Promise<PluginHostRecord[]> {
return requireHost().core.listHosts();
}
export function listCredentials(): Promise<PluginCredentialSummary[]> {
return requireHost().core.listCredentials();
}
export function usePluginApi(): PluginApiClient {
const bridge = requireHost();
return bridge.getApi(bridge.usePluginId());
+58 -2
View File
@@ -62,7 +62,6 @@ export type SystemRoleName = (typeof SYSTEM_ROLE_NAMES)[number];
*/
export const RESERVED_PERMISSION_PREFIXES = [
"hosts",
"snippets",
"credentials",
"admin",
] as const;
@@ -324,6 +323,23 @@ export interface PluginHttpContribution {
* a clash.
*/
legacyPaths?: string[];
/**
* Old URLs outside the plugin's namespace that something outside Termix
* still sends people to (an identity provider's callback, a published
* resolver URL). A request at `from`, or under it, is redirected to `to`
* under /plugin-api/<id>/ with the rest of the path and the query kept.
* Core routes win a clash.
*/
legacyRedirects?: PluginLegacyRedirect[];
}
export interface PluginLegacyRedirect {
/** An absolute path, e.g. "/users/oidc/callback". */
from: string;
/** A path in this plugin's router, e.g. "/callback". */
to: string;
/** 307 (the default) or 308 for a permanent move. */
status?: 307 | 308;
}
export interface PluginManifest {
@@ -869,6 +885,40 @@ function validateUiPresets(value: unknown, errors: string[]): void {
}
const LEGACY_PATH_PATTERN = /^\/[a-z][a-z0-9-]*(\/[A-Za-z0-9_.-]+)+$/;
const REDIRECT_PATH_PATTERN = /^(\/[A-Za-z0-9_.-]+)+$/;
const RESERVED_PREFIXES = ["/plugin-api", "/plugin-ws", "/plugin-assets"];
function validateLegacyRedirects(value: unknown, errors: string[]): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push('Field "contributes.http.legacyRedirects" must be an array');
return;
}
value.forEach((entry, index) => {
const at = `contributes.http.legacyRedirects[${index}]`;
if (!isPlainObject(entry)) {
errors.push(`${at} must be an object`);
return;
}
rejectUnknown(entry, ["from", "to", "status"], at, errors);
const { from, to, status } = entry;
if (typeof from !== "string" || !REDIRECT_PATH_PATTERN.test(from)) {
errors.push(`${at}.from must be an absolute path`);
} else if (
RESERVED_PREFIXES.some(
(prefix) => from === prefix || from.startsWith(`${prefix}/`),
)
) {
errors.push(`${at}.from cannot be under ${from.split("/")[1]}`);
}
if (typeof to !== "string" || !REDIRECT_PATH_PATTERN.test(to)) {
errors.push(`${at}.to must be a path in the plugin's router`);
}
if (status !== undefined && status !== 307 && status !== 308) {
errors.push(`${at}.status must be 307 or 308`);
}
});
}
function validateHttpContribution(
value: unknown,
@@ -880,7 +930,13 @@ function validateHttpContribution(
errors.push('Field "contributes.http" must be an object');
return;
}
rejectUnknown(value, ["legacyPaths"], "contributes.http", errors);
rejectUnknown(
value,
["legacyPaths", "legacyRedirects"],
"contributes.http",
errors,
);
validateLegacyRedirects(value.legacyRedirects, errors);
const paths = value.legacyPaths;
if (paths === undefined) return;
if (!Array.isArray(paths)) {
+3 -1
View File
@@ -14,7 +14,7 @@ import type {
PluginDisposables,
PluginLogger,
} from "./backend.js";
import type { TermixApp } from "./frontend.js";
import type { HostActionContribution, TermixApp } from "./frontend.js";
import type { PluginManifest } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
import type {
@@ -1967,6 +1967,8 @@ export interface RenderedPluginApp {
tabs: () => string[];
panels: () => string[];
hostActions: () => Array<{ id: string; tabType?: string }>;
/** This plugin's host actions that apply to `host`, as the shell sees them. */
hostActionsFor: (host: unknown) => HostActionContribution[];
hostProtocols: () => string[];
hostEditorSections: () => string[];
dashboardCards: () => string[];
+3
View File
@@ -112,6 +112,7 @@ const offline = () =>
pluginId: "core",
credentialType: type === "password" || type === "key",
supportsBackground: type !== "none",
quickConnect: true,
})),
loaded: true,
}),
@@ -138,6 +139,8 @@ const offline = () =>
getCustomKeybindings: async () => [],
setHostAutoTmux: async () => {},
getClientPreference: () => undefined,
listHosts: async () => [],
listCredentials: async () => [],
},
};
+2
View File
@@ -75,6 +75,8 @@ export function pluginVitestConfig(
"plugin-host",
"testing-host.tsx",
),
// Core's own modules behind sdk-ui and the test host import each
// other this way. Plugin code may not (eslint refuses it).
"@/types": path.join(repoRoot, "src", "types"),
"@": path.join(repoRoot, "src", "ui"),
}