feat: move snippets, keybinding actions and macros out of core into plugins

This commit is contained in:
LukeGus committed 2026-09-29 11:39:14 -05:00
1 parent d9bba10612
commit e0c30a4e6f
262 files changed
+9091 -10744

No files matched your search

@@ -668,9 +668,6 @@ export function registerHostBulkRoutes(
pin: hostData.pin || false,
sudoPassword: hostData.sudoPassword || null,
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
quickActions: hostData.quickActions
? JSON.stringify(hostData.quickActions)
: null,
...importedStatusCheck(hostData as Record<string, unknown>),
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
@@ -921,7 +918,6 @@ export function registerHostBulkRoutes(
jumpHosts: hostData.jumpHosts
? JSON.stringify(hostData.jumpHosts)
: null,
quickActions: null,
statusCheckEnabled: true,
statusCheckInterval: null,
terminalConfig: null,
@@ -179,7 +179,6 @@ export type NormalizedImportedHost = Record<string, unknown> & {
pin?: unknown;
sudoPassword?: unknown;
jumpHosts?: unknown;
quickActions?: unknown;
statusCheckEnabled?: unknown;
statusCheckInterval?: unknown;
terminalConfig?: unknown;
@@ -418,11 +417,11 @@ function parseJsonObject(value: unknown): Record<string, unknown> | null {
}
/**
* sshOptions, and terminalConfig in the shape 2.8 clients and exports read: core's own
* keys only (the SSH options and the startup snippet). The terminal's look
* and behavior are the ssh-terminal plugin's host settings, which it puts
* back through its hostPayloadLegacy. A sudo password 2.8 kept in
* terminal_config comes out as sudoPassword, for the sanitizers to handle.
* sshOptions, and terminalConfig in the shape 2.8 clients and exports read:
* core's own keys only, the SSH options. Everything else 2.8 kept there now
* belongs to plugins' host settings, which they put back through their
* hostPayloadLegacy. A sudo password 2.8 kept in terminal_config comes out as
* sudoPassword, for the sanitizers to handle.
*/
export function hostTerminalExport(host: Record<string, unknown>): {
sshOptions: HostSshOptions;
@@ -434,9 +433,6 @@ export function hostTerminalExport(host: Record<string, unknown>): {
host.sshOptions != null ? host.sshOptions : raw,
);
const terminalConfig: Record<string, unknown> = { ...sshOptions };
if (raw && raw.startupSnippetId !== undefined) {
terminalConfig.startupSnippetId = raw.startupSnippetId;
}
const legacySudo = !host.sudoPassword ? raw?.sudoPassword : undefined;
return {
sshOptions,
@@ -477,9 +473,9 @@ export function transformHostResponse(
vncUser: host.vncUser || undefined,
telnetUser: host.telnetUser || undefined,
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
quickActions: host.quickActions
? JSON.parse(host.quickActions as string)
: [],
// Moved into a plugin's host settings; its hostPayloadLegacy puts it
// back for 2.8 clients.
quickActions: undefined,
statusCheckEnabled:
host.statusCheckEnabled !== false && host.statusCheckEnabled !== 0,
statusCheckInterval:
-15
View File
@@ -194,7 +194,6 @@ router.post(
sudoPassword,
pin,
jumpHosts,
quickActions,
statusCheckEnabled,
statusCheckInterval,
terminalConfig,
@@ -296,9 +295,6 @@ router.post(
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
quickActions: Array.isArray(quickActions)
? JSON.stringify(quickActions)
: null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
@@ -682,7 +678,6 @@ router.post(
keyPassword: resolvedKeyPassword,
keyType: resolvedKeyType,
jumpHosts: [],
quickActions: [],
statusCheckEnabled: true,
statusCheckInterval: null,
notes: "",
@@ -794,7 +789,6 @@ router.put(
sudoPassword,
pin,
jumpHosts,
quickActions,
statusCheckEnabled,
statusCheckInterval,
terminalConfig,
@@ -897,9 +891,6 @@ router.put(
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
quickActions: Array.isArray(quickActions)
? JSON.stringify(quickActions)
: null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
@@ -1768,9 +1759,6 @@ router.get(
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
terminalConfig:
hostTerminalExport(resolvedHost).terminalConfig ?? null,
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
@@ -1903,9 +1891,6 @@ router.get(
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
terminalConfig:
hostTerminalExport(resolvedHost).terminalConfig ?? null,
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
@@ -1,13 +1,31 @@
const VALID_ACTION_TYPES = [
"copy",
"paste",
"sendControlCode",
"sendText",
"runSnippet",
"nextTab",
"previousTab",
"openCommandPalette",
];
import {
CORE_KEYBINDING_ACTIONS,
validateKeybindingActionParams,
type PluginKeybindingActionContribution,
} from "@termix/plugin-sdk/manifest";
export interface DeclaredKeybindingAction extends PluginKeybindingActionContribution {
pluginId: string;
}
let declaredActions: () => DeclaredKeybindingAction[] = () => [];
/** Set by the plugin runtime: every installed plugin's declared actions. */
export function setKeybindingActionSource(
source: () => DeclaredKeybindingAction[],
): void {
declaredActions = source;
}
/** The declaration a saved action type answers to, if a plugin made one. */
export function findKeybindingAction(
type: string,
): DeclaredKeybindingAction | undefined {
return declaredActions().find((action) => action.id === type);
}
const ACTION_TYPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9.-]{0,63}$/;
const MAX_LOOSE_VALUE = 65_536;
export function isValidKeyCombo(combo: unknown): boolean {
return (
@@ -22,22 +40,32 @@ export function isValidKeyCombo(combo: unknown): boolean {
);
}
/**
* A saved action. The shell's own types take no parameters; a type a plugin
* declared is checked against its declaration, whether or not the plugin is
* running. A type nobody declares (its plugin was removed) is only checked
* loosely, so the rest of the list can still be saved, and whichever plugin
* claims it later ignores what it cannot use.
*/
export function isValidKeybindingAction(action: unknown): boolean {
if (!action || typeof action !== "object") return false;
const type = (action as { type?: unknown }).type;
if (typeof type !== "string" || !VALID_ACTION_TYPES.includes(type))
if (!action || typeof action !== "object" || Array.isArray(action)) {
return false;
if (type === "sendText") {
return typeof (action as { text?: unknown }).text === "string";
}
if (type === "sendControlCode") {
const code = (action as { controlCode?: unknown }).controlCode;
return typeof code === "string" && /^[a-zA-Z]$/.test(code);
const record = action as Record<string, unknown>;
const type = record.type;
if (typeof type !== "string" || !ACTION_TYPE_PATTERN.test(type)) {
return false;
}
if (type === "runSnippet") {
return typeof (action as { snippetId?: unknown }).snippetId === "string";
}
return true;
if (CORE_KEYBINDING_ACTIONS.includes(type)) return true;
const declared = findKeybindingAction(type);
if (declared) return validateKeybindingActionParams(record, declared);
return Object.entries(record).every(
([, value]) =>
value === null ||
typeof value === "boolean" ||
typeof value === "number" ||
(typeof value === "string" && value.length <= MAX_LOOSE_VALUE),
);
}
export function isValidKeybinding(entry: unknown): boolean {
@@ -647,7 +647,7 @@ export function registerUserAdminRoutes(
if (outcome.status === "wipe_confirmation_required") {
return res.status(409).json({
error:
"This user has not logged in since the encryption upgrade, so their data cannot be recovered. Set confirmDataWipe to reset anyway and delete their hosts, credentials and snippets.",
"This user has not logged in since the encryption upgrade, so their data cannot be recovered. Set confirmDataWipe to reset anyway and delete their hosts, credentials and other saved data.",
code: "DATA_WIPE_REQUIRED",
});
}
@@ -71,7 +71,7 @@ export async function resetUserPassword(
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
// A plugin holding this user's data (snippets and anything else keyed by
// A plugin holding this user's data (anything keyed by
// refUser()) wipes it on this topic, since the user row itself survives.
pluginEvents.emit(TOPICS.userDataWiped, { userId });
@@ -416,7 +416,7 @@ export function registerUserPasswordResetRoutes(
if (outcome.status === "wipe_confirmation_required") {
return res.status(409).json({
error:
"This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets.",
"This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and other saved data.",
code: "DATA_WIPE_REQUIRED",
});
}
@@ -27,8 +27,6 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
pinAppRail: row?.pinAppRail ?? null,
expandAppRailOnHover: row?.expandAppRailOnHover ?? null,
showPinAppRailButton: row?.showPinAppRailButton ?? null,
foldersCollapsed: row?.foldersCollapsed ?? null,
confirmSnippetExecution: row?.confirmSnippetExecution ?? null,
disableUpdateCheck: row?.disableUpdateCheck ?? null,
confirmTabClose: row?.confirmTabClose ?? null,
hiddenRailTabs: row?.hiddenRailTabs ?? null,
@@ -37,7 +35,6 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
customThemes: row?.customThemes ?? null,
customKeybindings: row?.customKeybindings ?? null,
terminalDefaults: row?.terminalDefaults ?? null,
terminalMacros: row?.terminalMacros ?? null,
});
const connectionDefaultFields = ["terminalDefaults"] as const;
@@ -57,7 +54,7 @@ export function validateDefaultsJson(value: string): boolean {
* /user-preferences:
* get:
* summary: Get preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are legacy fields, kept here read-only for backward compatibility; the authoritative copy is GET /host-sidebar/preferences. foldersCollapsed is likewise legacy and read-only; its authoritative copy is the snippets plugin's own user settings.
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are legacy fields, kept here read-only for backward compatibility; the authoritative copy is GET /host-sidebar/preferences.
* tags:
* - User Preferences
* responses:
@@ -106,12 +103,6 @@ export function validateDefaultsJson(value: string): boolean {
* showPinAppRailButton:
* type: boolean
* nullable: true
* foldersCollapsed:
* type: boolean
* nullable: true
* confirmSnippetExecution:
* type: boolean
* nullable: true
* disableUpdateCheck:
* type: boolean
* nullable: true
@@ -134,7 +125,7 @@ export function validateDefaultsJson(value: string): boolean {
* customKeybindings:
* type: string
* nullable: true
* description: JSON-encoded array of the user's custom terminal keybindings.
* description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -157,7 +148,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* /user-preferences:
* put:
* summary: Update preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign. foldersCollapsed is no longer accepted either, now that it is a snippets plugin user setting.
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign. Values that moved into a feature's own user settings are ignored too.
* tags:
* - User Preferences
* requestBody:
@@ -189,8 +180,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* type: boolean
* showPinAppRailButton:
* type: boolean
* confirmSnippetExecution:
* type: boolean
* disableUpdateCheck:
* type: boolean
* confirmTabClose:
@@ -202,7 +191,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* description: JSON-encoded array of the user's saved global custom terminal themes.
* customKeybindings:
* type: string
* description: JSON-encoded array of the user's custom terminal keybindings.
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
* responses:
* 200:
* description: Preferences updated successfully.
@@ -221,14 +210,12 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
pinAppRail,
expandAppRailOnHover,
showPinAppRailButton,
confirmSnippetExecution,
disableUpdateCheck,
confirmTabClose,
hiddenRailTabs,
customThemes,
customKeybindings,
terminalDefaults,
terminalMacros,
} = req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
@@ -241,21 +228,19 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
pinAppRail?: boolean | null;
expandAppRailOnHover?: boolean | null;
showPinAppRailButton?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
customThemes?: string | null;
customKeybindings?: string | null;
terminalDefaults?: string | null;
terminalMacros?: string | null;
};
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
// longer writable here -- they moved to /host-sidebar/preferences as of the
// sidebar redesign. The columns stay in the table (read once as a
// migration seed by that route) but this endpoint silently ignores them if
// a stale client still sends them. foldersCollapsed similarly moved out,
// to the snippets plugin's own user settings.
// a stale client still sends them. The same goes for the preferences that
// moved into a plugin's own user settings.
const updates: UserPreferenceUpdate = {
updatedAt: new Date().toISOString(),
@@ -280,7 +265,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
customThemes,
customKeybindings,
terminalDefaults,
terminalMacros,
})) {
if (value !== undefined && value !== null && typeof value !== "string") {
return res.status(400).json({ error: `${key} must be a string` });
@@ -349,41 +333,12 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
}
}
if (terminalMacros !== undefined && terminalMacros !== null) {
let parsedMacros: unknown;
try {
parsedMacros = JSON.parse(terminalMacros);
} catch {
return res
.status(400)
.json({ error: "terminalMacros must be a JSON-encoded array" });
}
if (
terminalMacros.length > 512 * 1024 ||
!Array.isArray(parsedMacros) ||
parsedMacros.length > 100 ||
!parsedMacros.every(
(macro) =>
!!macro &&
typeof macro === "object" &&
typeof (macro as { id?: unknown }).id === "string" &&
typeof (macro as { name?: unknown }).name === "string" &&
Array.isArray((macro as { steps?: unknown }).steps),
)
) {
return res.status(400).json({
error: "terminalMacros must contain at most 100 valid macros",
});
}
}
const boolFields: Record<string, boolean | null | undefined> = {
commandAutocomplete,
commandPaletteEnabled,
pinAppRail,
expandAppRailOnHover,
showPinAppRailButton,
confirmSnippetExecution,
disableUpdateCheck,
confirmTabClose,
};
@@ -408,8 +363,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
updates.expandAppRailOnHover = expandAppRailOnHover;
if (showPinAppRailButton !== undefined)
updates.showPinAppRailButton = showPinAppRailButton;
if (confirmSnippetExecution !== undefined)
updates.confirmSnippetExecution = confirmSnippetExecution;
if (disableUpdateCheck !== undefined)
updates.disableUpdateCheck = disableUpdateCheck;
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
@@ -418,7 +371,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
updates.customKeybindings = customKeybindings;
if (terminalDefaults !== undefined)
updates.terminalDefaults = terminalDefaults;
if (terminalMacros !== undefined) updates.terminalMacros = terminalMacros;
if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" });