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

+2 -52
View File
@@ -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,
+117
View File
@@ -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;
},
};
}
+28 -4
View File
@@ -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();