feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)

This commit is contained in:
LukeGus committed 2026-09-23 08:59:24 -05:00
1 parent 3656caf168
commit c77301577f
16 files changed
+473 -52

No files matched your search

@@ -8,11 +8,8 @@ import {
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { withSshConnection } from "../ssh.js";
import { resolveSnippetCommand } from "../../../../../src/backend/database/routes/snippets-execution.js";
import {
createCurrentNotificationChannelRepository,
createCurrentSnippetRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { getSnippet, resolveSnippetCommandFor } from "../snippets.js";
import { sendAutomationNotification } from "../notify.js";
import { automationFetch } from "../http.js";
import { renderRecord, renderTemplate } from "../template.js";
@@ -176,11 +173,9 @@ async function runSnippet(
step: Extract<Step, { type: "run_snippet" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const snippet = await createCurrentSnippetRepository()
.findOwnedById(context.userId, step.snippetId)
.catch(() => null);
const snippet = await getSnippet(context.userId, step.snippetId);
if (!snippet) return fail("Snippet not found");
if (!snippet) return fail("Snippet not found, or the snippets plugin is off");
if (snippet.isNote) return fail("Notes cannot be executed on a host");
// Template values only ever reach the snippet through inputValues, never by
@@ -189,8 +184,9 @@ async function runSnippet(
const inputValues = renderRecord(step.inputValues, context.template) ?? {};
return runOnTargets(step.hostSelector, context, async (target) => {
const command = resolveSnippetCommand(
snippet.content,
const command = await resolveSnippetCommandFor(
context.userId,
step.snippetId,
{
ip: target.host.ip,
username: target.host.username,
@@ -199,6 +195,9 @@ async function runSnippet(
},
inputValues,
);
if (!command) {
return { output: "", error: "Snippet could not be resolved" };
}
if (context.dryRun) {
return { output: `Would run snippet on ${target.name}: ${command}` };
+3
View File
@@ -1,5 +1,6 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { setPluginServices } from "./snippets.js";
import { startAutomationsService, stopAutomationsService } from "./routes.js";
import {
startAutomationScheduler,
@@ -9,6 +10,8 @@ import {
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
setPluginServices(ctx.services);
ctx.disposables.add(() => setPluginServices(null));
startAutomationsService(
// The webhook route authenticates on its own per-automation token rather
// than a session, which is the point of an inbound webhook.
@@ -0,0 +1,64 @@
import type { PluginServices } from "@termix/plugin-sdk/backend";
let current: PluginServices | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginServices(services: PluginServices | null): void {
current = services;
}
/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */
interface SnippetsAccess {
get: (id: number) => Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null>;
resolveCommand: (
id: number,
vars: { ip?: string; username?: string; port?: number; name?: string },
inputValues?: Record<string, string>,
) => Promise<string | null>;
}
/**
* A snippet's command, resolved against a target host and run as userId.
* Null when the snippets plugin is off, the user may not use it, or the
* snippet does not exist. Optional: the run_snippet step fails with a clear
* message without it, rather than the plugin failing to activate.
*/
export async function resolveSnippetCommandFor(
userId: string,
id: number,
vars: { ip?: string; username?: string; port?: number; name?: string },
inputValues?: Record<string, string>,
): Promise<string | null> {
if (!current) return null;
try {
return await current
.get<SnippetsAccess>("snippets.access", { userId })
.resolveCommand(id, vars, inputValues);
} catch {
return null;
}
}
export async function getSnippet(
userId: string,
id: number,
): Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null> {
if (!current) return null;
try {
return await current
.get<SnippetsAccess>("snippets.access", { userId })
.get(id);
} catch {
return null;
}
}