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

+7 -1
View File
@@ -31,7 +31,8 @@
},
"optionalDependencies": {
"workspaces": "^1.0.0",
"network-topology": "^1.0.0"
"network-topology": "^1.0.0",
"snippets": "^1.0.0"
},
"requires": [
{
@@ -43,6 +44,11 @@
"service": "network-topology.graph",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "snippets.access",
"versionRange": "^1.0.0",
"optional": true
}
],
"providesSecret": [
+116
View File
@@ -51,3 +51,119 @@ export async function getNetworkTopology(
return null;
}
}
export interface SnippetSummary {
id: number;
name: string;
content: string;
description: string | null;
isNote: boolean;
folder: string | null;
}
/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */
interface SnippetsAccess {
list: () => Promise<SnippetSummary[]>;
get: (id: number) => Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null>;
create: (input: {
name: string;
content: string;
description?: string | null;
folder?: string | null;
}) => Promise<{ id: number; name: string }>;
update: (
id: number,
changes: {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
},
) => Promise<void>;
remove: (id: number) => Promise<boolean>;
}
/**
* The user's own snippets, or null when the snippets plugin is off or the
* user may not use it. Optional: the list_snippets tool disappears without
* it, and the propose_*_snippet tools fail with a clear message.
*/
export async function listSnippets(
userId: string,
): Promise<SnippetSummary[] | null> {
if (!current) return null;
try {
return await current
.get<SnippetsAccess>("snippets.access", { userId })
.list();
} 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;
}
}
/**
* Requires the snippets plugin, unlike the read helpers above: a failed
* proposal apply must surface as an error the user sees, not silently no-op.
*/
function requireSnippetsAccess(userId: string): SnippetsAccess {
if (!current) {
throw new Error("The snippets plugin is disabled");
}
return current.get<SnippetsAccess>("snippets.access", { userId });
}
export async function createSnippet(
userId: string,
input: {
name: string;
content: string;
description?: string | null;
folder?: string | null;
},
): Promise<{ id: number; name: string }> {
return requireSnippetsAccess(userId).create(input);
}
export async function updateSnippet(
userId: string,
id: number,
changes: {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
},
): Promise<void> {
return requireSnippetsAccess(userId).update(id, changes);
}
export async function deleteSnippet(
userId: string,
id: number,
): Promise<boolean> {
return requireSnippetsAccess(userId).remove(id);
}
+16 -24
View File
@@ -2,13 +2,18 @@ import {
createCurrentAutomationRepository,
createCurrentFleetRepository,
createCurrentHostRepository,
createCurrentSnippetRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { withSshConnection } from "../ssh.js";
import { getTool } from "./catalog.js";
import {
createSnippet,
updateSnippet,
deleteSnippet,
getSnippet,
} from "../services.js";
/** Approved commands get a bounded window rather than hanging the request. */
const COMMAND_TIMEOUT_MS = 60_000;
@@ -125,18 +130,15 @@ export async function applyProposal(
}
case "propose_create_snippet": {
const created = await createCurrentSnippetRepository().createSnippet(
userId,
{
name: requireString(payload.name, "name"),
content: requireString(payload.content, "content"),
description: optionalString(payload.description),
folder: optionalString(payload.folder),
} as any,
);
const created = await createSnippet(userId, {
name: requireString(payload.name, "name"),
content: requireString(payload.content, "content"),
description: optionalString(payload.description),
folder: optionalString(payload.folder),
});
return {
ok: true,
summary: `Created snippet ${(created as any)?.name ?? ""}`.trim(),
summary: `Created snippet ${created.name}`.trim(),
};
}
@@ -144,10 +146,7 @@ export async function applyProposal(
const snippetId = requireNumber(payload.snippetId, "snippetId");
const changes = (payload.changes ?? {}) as Record<string, unknown>;
const existing = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
const existing = await getSnippet(userId, snippetId);
if (!existing) throw new Error("Snippet not found");
const updates: Record<string, unknown> = {};
@@ -164,20 +163,13 @@ export async function applyProposal(
return { ok: false, summary: "Nothing to change" };
}
await createCurrentSnippetRepository().updateSnippet(
userId,
snippetId,
updates as any,
);
await updateSnippet(userId, snippetId, updates);
return { ok: true, summary: `Updated snippet ${snippetId}` };
}
case "propose_delete_snippet": {
const snippetId = requireNumber(payload.snippetId, "snippetId");
const deleted = await createCurrentSnippetRepository().deleteSnippet(
userId,
snippetId,
);
const deleted = await deleteSnippet(userId, snippetId);
if (!deleted) throw new Error("Snippet not found");
return { ok: true, summary: `Deleted snippet ${snippetId}` };
}
+9 -7
View File
@@ -5,10 +5,13 @@ import {
createCurrentFleetRepository,
createCurrentHomepageItemRepository,
createCurrentHostRepository,
createCurrentSnippetRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import { num, objectSchema, type AiTool } from "./types.js";
import { listSavedWorkspaces, getNetworkTopology } from "../services.js";
import {
listSavedWorkspaces,
getNetworkTopology,
listSnippets,
} from "../services.js";
/**
* Read tools project explicit fields rather than spreading rows. Redaction runs
@@ -103,15 +106,14 @@ export const readTools: AiTool[] = [
{
name: "list_snippets",
description:
"List the user's saved command snippets, including their folder and the command text.",
"List the user's saved command snippets, including their folder and the command text. Unavailable if the snippets plugin is disabled.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const snippets = await createCurrentSnippetRepository().listOwnedSnippets(
context.userId,
);
const snippets = await listSnippets(context.userId);
if (!snippets) return { unavailable: true, snippets: [] };
return {
snippets: snippets.map((snippet: any) => ({
snippets: snippets.map((snippet) => ({
id: snippet.id,
name: snippet.name,
content: snippet.content,
+10
View File
@@ -26,6 +26,16 @@
"network:serve",
"ui:surface"
],
"optionalDependencies": {
"snippets": "^1.0.0"
},
"requires": [
{
"service": "snippets.access",
"versionRange": "^1.0.0",
"optional": true
}
],
"contributes": {
"tabs": [
{
@@ -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;
}
}
@@ -0,0 +1,55 @@
export interface CommandHostVars {
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);
}
/**
* $HOST/$USER/$PORT/$NAME/$INPUT_n substitution for an ad hoc fleet command,
* typed directly into the "run on fleet" box rather than loaded from a saved
* snippet. Mirrors the snippets plugin's own resolveSnippetCommand and the
* frontend's src/ui/lib/snippet-variables.ts - kept as a small standalone
* copy rather than a cross-plugin dependency for one pure function with no
* snippet data of its own to fetch.
*/
export function resolveCommandVariables(
content: string,
host: CommandHostVars | 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;
}
+2 -2
View File
@@ -35,7 +35,7 @@ import {
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { buildPackageActionCommand } from "../../../../src/backend/hosts/metrics-shared/package-commands.js";
import { isValidPackageName } from "../../../../src/backend/hosts/metrics-shared/validation.js";
import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js";
import { resolveCommandVariables } from "./command-variables.js";
export const router = express.Router();
@@ -900,7 +900,7 @@ router.post(
return { success: false, error: "Host not found" };
}
const resolvedCommand = resolveSnippetCommand(
const resolvedCommand = resolveCommandVariables(
command,
{
ip: fullHost.ip,
@@ -16,11 +16,20 @@ import {
getSSHHosts,
type ServerMetrics,
} from "@/main-axios.ts";
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { hasSnippetInputs } from "@/lib/snippet-variables.ts";
import type { Snippet } from "@/types/ui-types.ts";
// Mirrors the snippets plugin's own hasSnippetInputs: a pure check for
// whether a snippet still has $INPUT_n placeholders once host variables are
// substituted, so the quick-action dialog knows to ask before running.
const SNIPPET_INPUT_PATTERN =
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
function hasSnippetInputs(content: string): boolean {
SNIPPET_INPUT_PATTERN.lastIndex = 0;
return SNIPPET_INPUT_PATTERN.test(content);
}
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { useTabsSafe } from "@/shell/TabContext.tsx";
import { useTranslation } from "@termix/plugin-sdk/frontend";
@@ -5,6 +5,7 @@ import type { ServerMetrics } from "@/main-axios";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
const SAMPLE = {
@@ -0,0 +1,68 @@
import { useEffect, useRef } from "react";
import type { TabProps } from "@termix/plugin-sdk/frontend";
import { TerminalTabContent } from "@/features/terminal/TerminalTabContent";
import type { TabRenderProps } from "@/shell/tab-registry";
import type { TerminalHandle } from "@/features/terminal/terminal-types";
import { registerSession, setActiveSession } from "./session-registry";
/**
* Wraps the core terminal tab component to register its ref in the session
* registry, so terminal.sendToActive/sendToSession (used by the snippets
* plugin, among others) can reach a live session without core exposing
* terminalRef through the public tab props.
*/
export function TerminalTabWithRegistry(props: TabProps) {
const tabRecord = props.tab as unknown as {
id: string;
terminalRef?: { current: TerminalHandle | null };
};
const host = props.host as
| {
id: string;
name?: string;
ip?: string;
username?: string;
port?: number;
}
| undefined;
const registeredRef = useRef<(() => void) | null>(null);
useEffect(() => {
let cancelled = false;
let attempts = 0;
const tryRegister = () => {
if (cancelled || registeredRef.current) return;
const ref = tabRecord.terminalRef?.current;
if (!ref) {
if (attempts++ < 40) setTimeout(tryRegister, 250);
return;
}
registeredRef.current = registerSession(
{
id: tabRecord.id,
hostId: host ? Number(host.id) || null : null,
hostName: host?.name,
ip: host?.ip,
username: host?.username,
port: host?.port,
},
ref,
);
};
tryRegister();
return () => {
cancelled = true;
registeredRef.current?.();
registeredRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabRecord.id]);
useEffect(() => {
if (props.isFocusedPane) setActiveSession(tabRecord.id);
}, [props.isFocusedPane, tabRecord.id]);
return <TerminalTabContent {...(props as unknown as TabRenderProps)} />;
}
+17 -5
View File
@@ -5,16 +5,15 @@ import type {
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import {
TerminalTabContent,
loadTerminal,
} from "@/features/terminal/TerminalTabContent";
import { loadTerminal } from "@/features/terminal/TerminalTabContent";
import TerminalApp from "@/features/terminal/TerminalApp";
import {
TERMINAL_DOCK_SLOT,
TERMINAL_OVERLAY_SLOT,
TERMINAL_TOOLBAR_SLOT,
} from "@/features/terminal/terminal-slots";
import { TerminalTabWithRegistry } from "./TerminalTabWithRegistry";
import { listSessions, sendToActive, sendToSession } from "./session-registry";
/** `?view=terminal` full-screen links. */
function TerminalStandalone({ hostId, params }: StandaloneViewProps) {
@@ -33,7 +32,7 @@ function TerminalStandalone({ hostId, params }: StandaloneViewProps) {
export function activate(app: TermixApp): void {
app.registerTab(
"terminal",
TerminalTabContent as unknown as ComponentType<TabProps>,
TerminalTabWithRegistry as unknown as ComponentType<TabProps>,
{
icon: Terminal,
titleKey: "nav.terminal",
@@ -63,4 +62,17 @@ export function activate(app: TermixApp): void {
app.declareActionSlot({ id: TERMINAL_TOOLBAR_SLOT, accepts: ["button"] });
app.declareActionSlot({ id: TERMINAL_DOCK_SLOT, accepts: ["component"] });
app.declareActionSlot({ id: TERMINAL_OVERLAY_SLOT, accepts: ["component"] });
// Lets another plugin (snippets) push text into a live terminal session
// without reaching into core's tab state itself.
app.registerAction("terminal.listSessions", () => listSessions());
app.registerAction("terminal.sendToActive", ((
text: string,
opts?: { run?: boolean },
) => sendToActive(text, opts)) as never);
app.registerAction("terminal.sendToSession", ((
sessionId: string,
text: string,
opts?: { run?: boolean },
) => sendToSession(sessionId, text, opts)) as never);
}
@@ -0,0 +1,61 @@
import type { TerminalHandle } from "@/features/terminal/terminal-types";
export interface TerminalSessionInfo {
id: string;
hostId: number | null;
hostName?: string;
ip?: string;
username?: string;
port?: number;
}
interface Session extends TerminalSessionInfo {
ref: TerminalHandle;
}
const sessions = new Map<string, Session>();
let activeId: string | null = null;
/** Called by the terminal tab wrapper on mount/unmount and focus change. */
export function registerSession(
info: TerminalSessionInfo,
ref: TerminalHandle,
): () => void {
sessions.set(info.id, { ...info, ref });
return () => {
sessions.delete(info.id);
if (activeId === info.id) activeId = null;
};
}
export function setActiveSession(id: string | null): void {
activeId = id;
}
export function listSessions(): TerminalSessionInfo[] {
return [...sessions.values()].map(({ ref: _ref, ...info }) => info);
}
function send(session: Session, text: string, run?: boolean): void {
if (run) session.ref.sendInput(text + "\r");
else session.ref.paste(text);
}
export function sendToActive(text: string, opts?: { run?: boolean }): boolean {
if (!activeId) return false;
const session = sessions.get(activeId);
if (!session) return false;
send(session, text, opts?.run);
return true;
}
export function sendToSession(
sessionId: string,
text: string,
opts?: { run?: boolean },
): boolean {
const session = sessions.get(sessionId);
if (!session) return false;
send(session, text, opts?.run);
return true;
}
@@ -50,4 +50,27 @@ describe(`${manifest.id} activate`, () => {
expect(app.registered.settingsComponents()).toEqual([]);
expect(app.registered.actions()).toEqual([]);
});
it("registers the terminal session actions snippets and other plugins call into", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.actions()).toEqual(
expect.arrayContaining([
"terminal.listSessions",
"terminal.sendToActive",
"terminal.sendToSession",
]),
);
});
it("terminal.sendToActive and terminal.listSessions report no session when none is registered", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
await expect(
rendered.app.invokeAction("terminal.listSessions"),
).resolves.toEqual([]);
await expect(
rendered.app.invokeAction("terminal.sendToActive", "echo hi", {
run: true,
}),
).resolves.toBe(false);
});
});