mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move snippets, keybinding actions and macros out of core into plugins
This commit is contained in:
1 parent
d9bba10612
commit
e0c30a4e6f
262 files changed
+9091
-10744
No files matched your search
@@ -4,58 +4,8 @@ export interface SnippetExecutionResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SnippetHostVars {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number | string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const INPUT_PATTERN =
|
||||
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
|
||||
|
||||
function replaceVar(content: string, name: string, value?: string): string {
|
||||
if (value === undefined) return content;
|
||||
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
|
||||
return content.replace(pattern, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors src/ui/lib/snippet-variables.ts resolveSnippetContent. Frontend and
|
||||
* backend are separate builds, so this is kept as a small standalone copy
|
||||
* rather than a shared package for one pure function.
|
||||
*/
|
||||
export function resolveSnippetCommand(
|
||||
content: string,
|
||||
host: SnippetHostVars | null,
|
||||
inputValues: Record<string, string> = {},
|
||||
): string {
|
||||
let resolved = content;
|
||||
|
||||
resolved = replaceVar(resolved, "HOST", host?.ip);
|
||||
resolved = replaceVar(resolved, "USER", host?.username);
|
||||
resolved = replaceVar(
|
||||
resolved,
|
||||
"PORT",
|
||||
host?.port !== undefined ? String(host.port) : undefined,
|
||||
);
|
||||
resolved = replaceVar(resolved, "NAME", host?.name);
|
||||
|
||||
resolved = resolved.replace(
|
||||
INPUT_PATTERN,
|
||||
(
|
||||
fullMatch,
|
||||
braceDigits: string | undefined,
|
||||
_label,
|
||||
plainDigits: string | undefined,
|
||||
) => {
|
||||
const key = `INPUT_${braceDigits ?? plainDigits}`;
|
||||
return key in inputValues ? inputValues[key] : fullMatch;
|
||||
},
|
||||
);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
export { resolveSnippetContent as resolveSnippetCommand } from "../shared/variables.js";
|
||||
export type { SnippetHostContext as SnippetHostVars } from "../shared/variables.js";
|
||||
|
||||
export function getSnippetExecutionTimeoutMs(
|
||||
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
PLUGIN_ID,
|
||||
readQuickActions,
|
||||
readStartupSnippetId,
|
||||
} from "../shared/host-settings.js";
|
||||
|
||||
/**
|
||||
* Registered as "snippets.hostImportNormalizer": a 2.8 or early 2.9 export
|
||||
* carried quick actions as a host field and the startup snippet inside
|
||||
* terminalConfig. An export that already carries this plugin's
|
||||
* pluginSettings has had them written by core.
|
||||
*/
|
||||
export function hostImportNormalizer(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const carried = (raw.pluginSettings as Record<string, unknown> | undefined)?.[
|
||||
PLUGIN_ID
|
||||
];
|
||||
if (
|
||||
carried &&
|
||||
typeof carried === "object" &&
|
||||
Object.keys(carried).length > 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const values: Record<string, unknown> = {};
|
||||
const quickActions = readQuickActions(raw.quickActions);
|
||||
if (quickActions.length > 0) values.quickActions = quickActions;
|
||||
|
||||
let terminalConfig = raw.terminalConfig;
|
||||
if (typeof terminalConfig === "string") {
|
||||
try {
|
||||
terminalConfig = JSON.parse(terminalConfig);
|
||||
} catch {
|
||||
terminalConfig = null;
|
||||
}
|
||||
}
|
||||
const startup = readStartupSnippetId(
|
||||
(terminalConfig as { startupSnippetId?: unknown } | null)?.startupSnippetId,
|
||||
);
|
||||
if (startup !== null) values.startupSnippetId = startup;
|
||||
|
||||
return Object.keys(values).length > 0 ? values : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered as "snippets.hostPayloadLegacy": quickActions and
|
||||
* terminalConfig.startupSnippetId in their 2.8 shape, for clients that
|
||||
* still read them (Termix-Mobile). Remove once the mobile app reads
|
||||
* pluginSettings.snippets.
|
||||
*/
|
||||
export function hostPayloadLegacy(
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {
|
||||
quickActions: readQuickActions(values.quickActions),
|
||||
};
|
||||
const startup = readStartupSnippetId(values.startupSnippetId);
|
||||
if (startup !== null) out.terminalConfig = { startupSnippetId: startup };
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What hostSettingsSync needs from the repository. */
|
||||
export interface SnippetSyncLookup {
|
||||
findSyncIdById: (id: number) => Promise<string | null>;
|
||||
findIdBySyncId: (syncId: string) => Promise<number | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered as "snippets.hostSettingsSync": both host settings hold local
|
||||
* snippet ids, so remote sync carries the snippets' sync ids instead, which
|
||||
* are the same on both sides.
|
||||
*/
|
||||
export function createHostSettingsSync(lookup: SnippetSyncLookup) {
|
||||
const toSyncId = async (id: number) => lookup.findSyncIdById(id);
|
||||
const toId = async (value: unknown) =>
|
||||
typeof value === "string" && value ? lookup.findIdBySyncId(value) : null;
|
||||
|
||||
return {
|
||||
exportValue: async (key: string, value: unknown) => {
|
||||
if (key === "startupSnippetId") {
|
||||
const id = readStartupSnippetId(value);
|
||||
return id === null ? null : await toSyncId(id);
|
||||
}
|
||||
if (key === "quickActions") {
|
||||
const actions = readQuickActions(value);
|
||||
const out = [];
|
||||
for (const action of actions) {
|
||||
const syncId = await toSyncId(action.snippetId);
|
||||
if (syncId) out.push({ name: action.name, snippetId: syncId });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
importValue: async (key: string, value: unknown) => {
|
||||
if (key === "startupSnippetId") return await toId(value);
|
||||
if (key === "quickActions") {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out = [];
|
||||
for (const entry of value) {
|
||||
const row = (entry ?? {}) as { name?: unknown; snippetId?: unknown };
|
||||
const id = await toId(row.snippetId);
|
||||
if (id !== null) {
|
||||
out.push({
|
||||
name: typeof row.name === "string" ? row.name : "",
|
||||
snippetId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,15 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { snippetAccess, snippetFolders, snippets } from "./tables.js";
|
||||
import { createSnippetRepository } from "./repository.js";
|
||||
import { registerSnippetRoutes } from "./routes.js";
|
||||
import { resolveSnippetCommand } from "./execution.js";
|
||||
import {
|
||||
extractSnippetInputs,
|
||||
resolveSnippetContent,
|
||||
} from "../shared/variables.js";
|
||||
import {
|
||||
createHostSettingsSync,
|
||||
hostImportNormalizer,
|
||||
hostPayloadLegacy,
|
||||
} from "./host-import.js";
|
||||
|
||||
/** What other plugins get from ctx.services.get("snippets.access", {userId}). */
|
||||
export interface SnippetsService {
|
||||
@@ -74,8 +82,24 @@ export async function activate(ctx: PluginContext) {
|
||||
type: "snippets",
|
||||
table: snippetsTable,
|
||||
order: 60,
|
||||
// Host quick actions and keybindings in core point at snippets by this.
|
||||
answersTo: ["commandSnippet"],
|
||||
});
|
||||
|
||||
// Host settings that name a snippet by its local id, and the 2.8 host
|
||||
// fields they came from. Keybindings name one through the runSnippet
|
||||
// declaration in the manifest, which core translates over sync.
|
||||
ctx.registry.provide("snippets.hostImportNormalizer", hostImportNormalizer);
|
||||
ctx.registry.provide("snippets.hostPayloadLegacy", hostPayloadLegacy);
|
||||
ctx.registry.provide(
|
||||
"snippets.hostSettingsSync",
|
||||
createHostSettingsSync(repo),
|
||||
);
|
||||
|
||||
// $HOST and $INPUT_n handling for commands other plugins run (fleets). A
|
||||
// pure function, so it goes through the registry rather than a service
|
||||
// gated on snippets.view.
|
||||
ctx.registry.provide("snippets.variables", {
|
||||
resolve: resolveSnippetContent,
|
||||
extractInputs: extractSnippetInputs,
|
||||
});
|
||||
|
||||
// The user row survives a password-reset data wipe, so it never triggers
|
||||
@@ -133,7 +157,7 @@ export async function activate(ctx: PluginContext) {
|
||||
if (!userId) return null;
|
||||
const snippet = await findAccessible(userId, id);
|
||||
if (!snippet) return null;
|
||||
return resolveSnippetCommand(snippet.content, vars, inputValues);
|
||||
return resolveSnippetContent(snippet.content, vars, inputValues);
|
||||
},
|
||||
create: async (input) => {
|
||||
const userId = ctx.currentActor();
|
||||
|
||||
@@ -882,6 +882,27 @@ export function createSnippetRepository(
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
/** A snippet's sync id, for a host setting that names it over sync. */
|
||||
async findSyncIdById(snippetId: number): Promise<string | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select({ syncId: snippets.syncId })
|
||||
.from(snippets)
|
||||
.where(eq(snippets.id, snippetId))
|
||||
.limit(1);
|
||||
return (rows[0]?.syncId as string | null | undefined) ?? null;
|
||||
},
|
||||
|
||||
async findIdBySyncId(syncId: string): Promise<number | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select({ id: snippets.id })
|
||||
.from(snippets)
|
||||
.where(eq(snippets.syncId, syncId))
|
||||
.limit(1);
|
||||
return (rows[0]?.id as number | undefined) ?? null;
|
||||
},
|
||||
|
||||
/** A user's role ids, for the sharing filters above. */
|
||||
async listUserRoleIds(userId: string): Promise<number[]> {
|
||||
const drizzle = await client();
|
||||
|
||||
Reference in new issue
Block a user