feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

@@ -0,0 +1,90 @@
import type { HostSelector } from "../../../../../src/types/automations.js";
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
import { createCurrentFleetRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { PermissionManager } from "../../../../../src/backend/utils/permission-manager.js";
import type { StepExecutionContext } from "./types.js";
/** A host the caller is allowed to act on. `host` is always resolved. */
export interface ResolvedTarget {
id: number;
name: string;
host: NonNullable<Awaited<ReturnType<typeof resolveHostById>>>;
}
/**
* Turns a selector into the hosts a step may actually act on.
*
* Access is checked here, at execution time rather than when the automation
* was saved, so a permission revoked after the fact takes effect on the next
* run. resolveHostById performs its own connect-level check and returns null
* when the owner can no longer reach the host.
*/
export async function resolveTargets(
selector: HostSelector,
context: StepExecutionContext,
): Promise<{ targets: ResolvedTarget[]; skipped: number[] }> {
const ids = await selectorHostIds(selector, context);
const targets: ResolvedTarget[] = [];
const skipped: number[] = [];
for (const id of ids) {
const host = await resolveHostById(id, context.userId);
if (!host) {
skipped.push(id);
continue;
}
targets.push({ id, name: host.name || host.ip, host });
}
return { targets, skipped };
}
async function selectorHostIds(
selector: HostSelector,
context: StepExecutionContext,
): Promise<number[]> {
switch (selector.kind) {
case "host":
return [selector.hostId];
case "hosts":
return selector.hostIds;
case "trigger":
return context.triggerHostId ? [context.triggerHostId] : [];
case "fleet":
return fleetHostIds(selector.fleetId, context.userId);
case "all":
return allAccessibleHostIds(context.userId);
default:
return [];
}
}
async function fleetHostIds(
fleetId: number,
userId: string,
): Promise<number[]> {
try {
const repository = createCurrentFleetRepository();
const members = await repository.listEffectiveMembers(userId, fleetId);
return members.map((member) => member.id);
} catch {
return [];
}
}
async function allAccessibleHostIds(userId: string): Promise<number[]> {
try {
const { createCurrentHostRepository } =
await import("../../../../../src/backend/database/repositories/factory.js");
const hosts = await createCurrentHostRepository().listByUserId(userId);
const ids = hosts.map((host) => host.id);
const allowed =
await PermissionManager.getInstance().filterAccessibleHostIds(
userId,
ids,
);
return ids.filter((id) => allowed.has(id));
} catch {
return [];
}
}
@@ -0,0 +1,407 @@
import {
DEFAULT_STEP_TIMEOUT_MS,
type Step,
} from "../../../../../src/types/automations.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execElevated,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import {
createFleetSshFactory,
getFleetPoolKey,
} from "../../../../../src/backend/hosts/ssh-client-factory.js";
import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js";
import { resolveSnippetCommand } from "../../../../../src/backend/database/routes/snippets-execution.js";
import {
createCurrentNotificationChannelRepository,
createCurrentSnippetRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import { sendAutomationNotification } from "../notify.js";
import { automationFetch } from "../http.js";
import { renderRecord, renderTemplate } from "../template.js";
import { resolveTargets, type ResolvedTarget } from "./host-targets.js";
import {
fail,
ok,
stepTimeout,
type StepExecutionContext,
type StepResult,
} from "./types.js";
/**
* One executor per step type.
*
* Anything that leaves Termix checks context.dryRun first and reports what it
* would have done instead of doing it, so an automation can be exercised
* safely while it is being built.
*/
export async function executeStep(
step: Step,
context: StepExecutionContext,
): Promise<StepResult> {
switch (step.type) {
case "notify":
return runNotify(step, context);
case "http":
return runHttp(step, context);
case "run_command":
return runCommand(step, context);
case "run_snippet":
return runSnippet(step, context);
case "docker":
return runDocker(step, context);
case "tunnel":
return runTunnel(step, context);
case "wol":
return runWol(step, context);
case "wait":
return runWait(step, context);
case "set_var":
return runSetVar(step, context);
case "stop":
return {
success: true,
halt: { status: step.status ?? "success" },
output: `Stopped with status ${step.status ?? "success"}`,
};
default:
return fail(`Unsupported step type: ${(step as Step).type}`);
}
}
async function runNotify(
step: Extract<Step, { type: "notify" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const title = renderTemplate(step.title ?? "", context.template);
const body = renderTemplate(step.body ?? "", context.template);
if (step.channelIds.length === 0) {
return fail("No notification channels selected");
}
if (context.dryRun) {
return ok(
`Would notify ${step.channelIds.length} channel(s): ${title || body}`,
);
}
const repository = createCurrentNotificationChannelRepository();
const channels = await repository.listNotificationChannels(context.userId);
const selected = channels.filter((channel) =>
step.channelIds.includes(channel.id),
);
if (selected.length === 0) {
return fail("Selected notification channels no longer exist");
}
let delivered = 0;
const errors: string[] = [];
for (const channel of selected) {
if (!channel.enabled) continue;
try {
await sendAutomationNotification(
{ id: channel.id, type: channel.type, config: channel.config },
{
title,
body,
severity: step.severity ?? "warning",
context: context.template,
},
);
delivered++;
} catch (error) {
errors.push(
`${channel.name}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
if (delivered === 0) {
return fail(errors.join("; ") || "No enabled channels to notify");
}
return ok(
`Notified ${delivered} channel(s)${errors.length ? `; ${errors.join("; ")}` : ""}`,
);
}
async function runHttp(
step: Extract<Step, { type: "http" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const url = renderTemplate(step.url, context.template);
const headers = renderRecord(step.headers, context.template);
const body = step.body
? renderTemplate(step.body, context.template)
: undefined;
if (context.dryRun) {
return ok(`Would ${step.method} ${url}`);
}
try {
const response = await automationFetch(url, {
method: step.method,
headers,
body,
allowPrivateNetwork: step.allowPrivateNetwork,
timeoutMs: stepTimeout(context, step.timeoutMs, DEFAULT_STEP_TIMEOUT_MS),
});
const text = await response.text();
const summary = `HTTP ${response.status} ${response.statusText}\n${text}`;
return response.ok ? ok(summary) : fail(`HTTP ${response.status}`, summary);
} catch (error) {
return fail(error instanceof Error ? error.message : String(error));
}
}
async function runCommand(
step: Extract<Step, { type: "run_command" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const command = renderTemplate(step.command, context.template);
return runOnTargets(step.hostSelector, context, async (target) => {
if (context.dryRun) {
return { output: `Would run on ${target.name}: ${command}` };
}
return execOnHost(
target.host,
command,
step.elevated,
context,
step.timeoutMs,
);
});
}
async function runSnippet(
step: Extract<Step, { type: "run_snippet" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const snippet = await createCurrentSnippetRepository()
.findOwnedById(context.userId, step.snippetId)
.catch(() => null);
if (!snippet) return fail("Snippet not found");
if (snippet.isNote) return fail("Notes cannot be executed on a host");
// Template values only ever reach the snippet through inputValues, never by
// rewriting the snippet body, so automation variables cannot inject snippet
// syntax of their own.
const inputValues = renderRecord(step.inputValues, context.template) ?? {};
return runOnTargets(step.hostSelector, context, async (target) => {
const command = resolveSnippetCommand(
snippet.content,
{
ip: target.host.ip,
username: target.host.username,
port: target.host.port,
name: target.host.name,
},
inputValues,
);
if (context.dryRun) {
return { output: `Would run snippet on ${target.name}: ${command}` };
}
return execOnHost(
target.host,
command,
step.elevated,
context,
step.timeoutMs,
);
});
}
async function runDocker(
step: Extract<Step, { type: "docker" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const container = renderTemplate(step.container, context.template);
if (!container) return fail("Container name is required");
return runOnTargets(step.hostSelector, context, async (target) => {
if (context.dryRun) {
return {
output: `Would ${step.action} container ${container} on ${target.name}`,
};
}
// The Docker HTTP routes are tied to an interactive session, so run the
// equivalent command over the same pooled SSH connection everything else
// uses. The container name is quoted because it comes from a template.
const command = `docker ${step.action} ${shellSingleQuote(container)}`;
return execOnHost(target.host, command, false, context, step.timeoutMs);
});
}
async function runTunnel(
step: Extract<Step, { type: "tunnel" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const name = renderTemplate(step.tunnelName, context.template);
if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`);
try {
const manager =
await import("../../../../../src/backend/hosts/tunnel/manager.js");
const config = manager.tunnelConfigs?.get(name);
if (!config) return fail(`Tunnel "${name}" is not configured`);
if (step.action === "connect") {
await manager.connectSSHTunnel(config);
return ok(`Tunnel ${name} connected`);
}
// shouldRetry false, otherwise the manager immediately reconnects the
// tunnel the automation just asked it to drop.
manager.manualDisconnects.add(name);
await manager.handleDisconnect(name, config, false);
return ok(`Tunnel ${name} disconnected`);
} catch (error) {
return fail(error instanceof Error ? error.message : String(error));
}
}
async function runWol(
step: Extract<Step, { type: "wol" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const host = await resolveTargets(
{ kind: "host", hostId: step.hostId },
context,
);
const target = host.targets[0];
if (!target?.host) return fail("Host not found or not accessible");
const mac = (target.host as { macAddress?: string }).macAddress;
if (!mac) return fail("Host has no MAC address configured");
if (context.dryRun) return ok(`Would wake ${target.name} (${mac})`);
try {
const { sendWakeOnLan, isValidMac } =
await import("../../../../../src/backend/utils/wake-on-lan.js");
if (!isValidMac(mac)) return fail(`Invalid MAC address: ${mac}`);
await sendWakeOnLan(mac);
return ok(`Sent magic packet to ${target.name}`);
} catch (error) {
return fail(error instanceof Error ? error.message : String(error));
}
}
async function runWait(
step: Extract<Step, { type: "wait" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const requested = Math.max(step.seconds, 0) * 1000;
const waitMs = Math.min(
requested,
stepTimeout(context, undefined, requested),
);
if (context.dryRun) return ok(`Would wait ${step.seconds}s`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
return ok(`Waited ${Math.round(waitMs / 1000)}s`);
}
async function runSetVar(
step: Extract<Step, { type: "set_var" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const value = renderTemplate(step.value, context.template);
return ok(`${step.name} = ${value}`, { [step.name]: value });
}
/**
* Runs a per-host action across a selector's targets. One host failing does
* not stop the others, matching how fleet execution behaves.
*/
async function runOnTargets(
selector: Extract<Step, { type: "run_command" }>["hostSelector"],
context: StepExecutionContext,
run: (target: ResolvedTarget) => Promise<{ output?: string; error?: string }>,
): Promise<StepResult> {
const { targets, skipped } = await resolveTargets(selector, context);
if (targets.length === 0) {
return fail(
skipped.length > 0
? `No accessible hosts (${skipped.length} skipped)`
: "No hosts matched the selector",
);
}
const results = await Promise.allSettled(
targets.map(async (target) => ({
target,
result: await run(target),
})),
);
const lines: string[] = [];
let failures = 0;
for (const [index, settled] of results.entries()) {
const name = targets[index].name;
if (settled.status === "rejected") {
failures++;
lines.push(`${name}: ${String(settled.reason)}`);
continue;
}
const { result } = settled.value;
if (result.error) {
failures++;
lines.push(`${name}: ${result.error}`);
} else {
lines.push(`${name}: ${result.output ?? "ok"}`);
}
}
if (skipped.length > 0) {
lines.push(`${skipped.length} host(s) skipped: no access`);
}
const output = lines.join("\n");
return failures > 0 && failures === targets.length
? fail(`All ${failures} host(s) failed`, output)
: ok(output);
}
async function execOnHost(
host: ResolvedTarget["host"],
command: string,
elevated: boolean | undefined,
context: StepExecutionContext,
timeoutMs: number | undefined,
): Promise<{ output?: string; error?: string }> {
const sshHost = host as ResolvedTarget["host"] & { sudoPassword?: string };
const timeout = stepTimeout(context, timeoutMs, DEFAULT_STEP_TIMEOUT_MS);
if (timeout <= 0) return { error: "Run deadline exceeded" };
try {
const result = await withConnection(
getFleetPoolKey(sshHost),
createFleetSshFactory(sshHost),
async (client) => {
if (elevated) {
return execElevated(client, command, sshHost.sudoPassword, {
timeoutMs: timeout,
});
}
return execCommand(client, command, timeout);
},
);
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
if (result.code === 0 || result.code === null) {
return { output: output || "(no output)" };
}
return { error: `Exited with code ${result.code}`, output };
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
}
@@ -0,0 +1,58 @@
import type { Step } from "../../../../../src/types/automations.js";
import type { TemplateContext } from "../template.js";
/** What every executor returns. `output` is what later steps can read. */
export interface StepResult {
success: boolean;
output?: string;
error?: string;
/** Merged into the run's variables, for set_var and friends. */
vars?: Record<string, string>;
/** Set by the stop step to end the run early. */
halt?: { status: "success" | "failed" };
}
export interface StepExecutionContext {
userId: string;
automationId: number;
runId: number;
/** Nothing that leaves Termix may actually happen when this is set. */
dryRun: boolean;
template: TemplateContext;
/** Host the trigger fired for, when there was one. */
triggerHostId?: number;
/** Automations already on the stack, to refuse recursion. */
ancestry: number[];
depth: number;
/** Wall-clock deadline for the whole run. */
deadlineAt: number;
signal?: AbortSignal;
}
export type StepExecutor<T extends Step = Step> = (
step: T,
context: StepExecutionContext,
) => Promise<StepResult>;
export function ok(output?: string, vars?: Record<string, string>): StepResult {
return { success: true, output, vars };
}
export function fail(error: string, output?: string): StepResult {
return { success: false, error, output };
}
/** Milliseconds left before the run's overall deadline. */
export function remainingMs(context: StepExecutionContext): number {
return Math.max(context.deadlineAt - Date.now(), 0);
}
/** A step's timeout, clamped so it can never outlive the run. */
export function stepTimeout(
context: StepExecutionContext,
requested: number | undefined,
fallback: number,
): number {
const wanted = requested && requested > 0 ? requested : fallback;
return Math.max(Math.min(wanted, remainingMs(context)), 0);
}