mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
This commit is contained in:
1 parent
3656caf168
commit
c77301577f
16 files changed
+473
-52
No files matched your search
@@ -31,7 +31,8 @@
|
|||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"workspaces": "^1.0.0",
|
"workspaces": "^1.0.0",
|
||||||
"network-topology": "^1.0.0"
|
"network-topology": "^1.0.0",
|
||||||
|
"snippets": "^1.0.0"
|
||||||
},
|
},
|
||||||
"requires": [
|
"requires": [
|
||||||
{
|
{
|
||||||
@@ -43,6 +44,11 @@
|
|||||||
"service": "network-topology.graph",
|
"service": "network-topology.graph",
|
||||||
"versionRange": "^1.0.0",
|
"versionRange": "^1.0.0",
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service": "snippets.access",
|
||||||
|
"versionRange": "^1.0.0",
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"providesSecret": [
|
"providesSecret": [
|
||||||
|
|||||||
@@ -51,3 +51,119 @@ export async function getNetworkTopology(
|
|||||||
return null;
|
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);
|
||||||
|
}
|
||||||
@@ -2,13 +2,18 @@ import {
|
|||||||
createCurrentAutomationRepository,
|
createCurrentAutomationRepository,
|
||||||
createCurrentFleetRepository,
|
createCurrentFleetRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentSnippetRepository,
|
|
||||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||||
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
||||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||||
import { withSshConnection } from "../ssh.js";
|
import { withSshConnection } from "../ssh.js";
|
||||||
import { getTool } from "./catalog.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. */
|
/** Approved commands get a bounded window rather than hanging the request. */
|
||||||
const COMMAND_TIMEOUT_MS = 60_000;
|
const COMMAND_TIMEOUT_MS = 60_000;
|
||||||
@@ -125,18 +130,15 @@ export async function applyProposal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "propose_create_snippet": {
|
case "propose_create_snippet": {
|
||||||
const created = await createCurrentSnippetRepository().createSnippet(
|
const created = await createSnippet(userId, {
|
||||||
userId,
|
name: requireString(payload.name, "name"),
|
||||||
{
|
content: requireString(payload.content, "content"),
|
||||||
name: requireString(payload.name, "name"),
|
description: optionalString(payload.description),
|
||||||
content: requireString(payload.content, "content"),
|
folder: optionalString(payload.folder),
|
||||||
description: optionalString(payload.description),
|
});
|
||||||
folder: optionalString(payload.folder),
|
|
||||||
} as any,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
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 snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||||
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
const existing = await createCurrentSnippetRepository().findOwnedById(
|
const existing = await getSnippet(userId, snippetId);
|
||||||
userId,
|
|
||||||
snippetId,
|
|
||||||
);
|
|
||||||
if (!existing) throw new Error("Snippet not found");
|
if (!existing) throw new Error("Snippet not found");
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
@@ -164,20 +163,13 @@ export async function applyProposal(
|
|||||||
return { ok: false, summary: "Nothing to change" };
|
return { ok: false, summary: "Nothing to change" };
|
||||||
}
|
}
|
||||||
|
|
||||||
await createCurrentSnippetRepository().updateSnippet(
|
await updateSnippet(userId, snippetId, updates);
|
||||||
userId,
|
|
||||||
snippetId,
|
|
||||||
updates as any,
|
|
||||||
);
|
|
||||||
return { ok: true, summary: `Updated snippet ${snippetId}` };
|
return { ok: true, summary: `Updated snippet ${snippetId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "propose_delete_snippet": {
|
case "propose_delete_snippet": {
|
||||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||||
const deleted = await createCurrentSnippetRepository().deleteSnippet(
|
const deleted = await deleteSnippet(userId, snippetId);
|
||||||
userId,
|
|
||||||
snippetId,
|
|
||||||
);
|
|
||||||
if (!deleted) throw new Error("Snippet not found");
|
if (!deleted) throw new Error("Snippet not found");
|
||||||
return { ok: true, summary: `Deleted snippet ${snippetId}` };
|
return { ok: true, summary: `Deleted snippet ${snippetId}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import {
|
|||||||
createCurrentFleetRepository,
|
createCurrentFleetRepository,
|
||||||
createCurrentHomepageItemRepository,
|
createCurrentHomepageItemRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentSnippetRepository,
|
|
||||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||||
import { num, objectSchema, type AiTool } from "./types.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
|
* Read tools project explicit fields rather than spreading rows. Redaction runs
|
||||||
@@ -103,15 +106,14 @@ export const readTools: AiTool[] = [
|
|||||||
{
|
{
|
||||||
name: "list_snippets",
|
name: "list_snippets",
|
||||||
description:
|
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",
|
category: "read",
|
||||||
parameters: objectSchema({}),
|
parameters: objectSchema({}),
|
||||||
handler: async (_args, context) => {
|
handler: async (_args, context) => {
|
||||||
const snippets = await createCurrentSnippetRepository().listOwnedSnippets(
|
const snippets = await listSnippets(context.userId);
|
||||||
context.userId,
|
if (!snippets) return { unavailable: true, snippets: [] };
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
snippets: snippets.map((snippet: any) => ({
|
snippets: snippets.map((snippet) => ({
|
||||||
id: snippet.id,
|
id: snippet.id,
|
||||||
name: snippet.name,
|
name: snippet.name,
|
||||||
content: snippet.content,
|
content: snippet.content,
|
||||||
|
|||||||
@@ -26,6 +26,16 @@
|
|||||||
"network:serve",
|
"network:serve",
|
||||||
"ui:surface"
|
"ui:surface"
|
||||||
],
|
],
|
||||||
|
"optionalDependencies": {
|
||||||
|
"snippets": "^1.0.0"
|
||||||
|
},
|
||||||
|
"requires": [
|
||||||
|
{
|
||||||
|
"service": "snippets.access",
|
||||||
|
"versionRange": "^1.0.0",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"tabs": [
|
"tabs": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ import {
|
|||||||
shellSingleQuote,
|
shellSingleQuote,
|
||||||
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||||
import { withSshConnection } from "../ssh.js";
|
import { withSshConnection } from "../ssh.js";
|
||||||
import { resolveSnippetCommand } from "../../../../../src/backend/database/routes/snippets-execution.js";
|
import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||||
import {
|
import { getSnippet, resolveSnippetCommandFor } from "../snippets.js";
|
||||||
createCurrentNotificationChannelRepository,
|
|
||||||
createCurrentSnippetRepository,
|
|
||||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
|
||||||
import { sendAutomationNotification } from "../notify.js";
|
import { sendAutomationNotification } from "../notify.js";
|
||||||
import { automationFetch } from "../http.js";
|
import { automationFetch } from "../http.js";
|
||||||
import { renderRecord, renderTemplate } from "../template.js";
|
import { renderRecord, renderTemplate } from "../template.js";
|
||||||
@@ -176,11 +173,9 @@ async function runSnippet(
|
|||||||
step: Extract<Step, { type: "run_snippet" }>,
|
step: Extract<Step, { type: "run_snippet" }>,
|
||||||
context: StepExecutionContext,
|
context: StepExecutionContext,
|
||||||
): Promise<StepResult> {
|
): Promise<StepResult> {
|
||||||
const snippet = await createCurrentSnippetRepository()
|
const snippet = await getSnippet(context.userId, step.snippetId);
|
||||||
.findOwnedById(context.userId, step.snippetId)
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
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");
|
if (snippet.isNote) return fail("Notes cannot be executed on a host");
|
||||||
|
|
||||||
// Template values only ever reach the snippet through inputValues, never by
|
// 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) ?? {};
|
const inputValues = renderRecord(step.inputValues, context.template) ?? {};
|
||||||
|
|
||||||
return runOnTargets(step.hostSelector, context, async (target) => {
|
return runOnTargets(step.hostSelector, context, async (target) => {
|
||||||
const command = resolveSnippetCommand(
|
const command = await resolveSnippetCommandFor(
|
||||||
snippet.content,
|
context.userId,
|
||||||
|
step.snippetId,
|
||||||
{
|
{
|
||||||
ip: target.host.ip,
|
ip: target.host.ip,
|
||||||
username: target.host.username,
|
username: target.host.username,
|
||||||
@@ -199,6 +195,9 @@ async function runSnippet(
|
|||||||
},
|
},
|
||||||
inputValues,
|
inputValues,
|
||||||
);
|
);
|
||||||
|
if (!command) {
|
||||||
|
return { output: "", error: "Snippet could not be resolved" };
|
||||||
|
}
|
||||||
|
|
||||||
if (context.dryRun) {
|
if (context.dryRun) {
|
||||||
return { output: `Would run snippet on ${target.name}: ${command}` };
|
return { output: `Would run snippet on ${target.name}: ${command}` };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
import { setPluginSsh } from "./ssh.js";
|
import { setPluginSsh } from "./ssh.js";
|
||||||
|
import { setPluginServices } from "./snippets.js";
|
||||||
import { startAutomationsService, stopAutomationsService } from "./routes.js";
|
import { startAutomationsService, stopAutomationsService } from "./routes.js";
|
||||||
import {
|
import {
|
||||||
startAutomationScheduler,
|
startAutomationScheduler,
|
||||||
@@ -9,6 +10,8 @@ import {
|
|||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
setPluginSsh(ctx.ssh);
|
setPluginSsh(ctx.ssh);
|
||||||
ctx.disposables.add(() => setPluginSsh(null));
|
ctx.disposables.add(() => setPluginSsh(null));
|
||||||
|
setPluginServices(ctx.services);
|
||||||
|
ctx.disposables.add(() => setPluginServices(null));
|
||||||
startAutomationsService(
|
startAutomationsService(
|
||||||
// The webhook route authenticates on its own per-automation token rather
|
// The webhook route authenticates on its own per-automation token rather
|
||||||
// than a session, which is the point of an inbound webhook.
|
// 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;
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||||
import { buildPackageActionCommand } from "../../../../src/backend/hosts/metrics-shared/package-commands.js";
|
import { buildPackageActionCommand } from "../../../../src/backend/hosts/metrics-shared/package-commands.js";
|
||||||
import { isValidPackageName } from "../../../../src/backend/hosts/metrics-shared/validation.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();
|
export const router = express.Router();
|
||||||
|
|
||||||
@@ -900,7 +900,7 @@ router.post(
|
|||||||
return { success: false, error: "Host not found" };
|
return { success: false, error: "Host not found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedCommand = resolveSnippetCommand(
|
const resolvedCommand = resolveCommandVariables(
|
||||||
command,
|
command,
|
||||||
{
|
{
|
||||||
ip: fullHost.ip,
|
ip: fullHost.ip,
|
||||||
|
|||||||
@@ -16,11 +16,20 @@ import {
|
|||||||
getSSHHosts,
|
getSSHHosts,
|
||||||
type ServerMetrics,
|
type ServerMetrics,
|
||||||
} from "@/main-axios.ts";
|
} from "@/main-axios.ts";
|
||||||
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
|
import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui";
|
||||||
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
|
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
|
||||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
import { hasSnippetInputs } from "@/lib/snippet-variables.ts";
|
|
||||||
import type { Snippet } from "@/types/ui-types.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 { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||||
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ServerMetrics } from "@/main-axios";
|
|||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", () => ({
|
||||||
useTranslation: () => ({ t: (key: string) => key }),
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const SAMPLE = {
|
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)} />;
|
||||||
|
}
|
||||||
@@ -5,16 +5,15 @@ import type {
|
|||||||
TabProps,
|
TabProps,
|
||||||
TermixApp,
|
TermixApp,
|
||||||
} from "@termix/plugin-sdk/frontend";
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import {
|
import { loadTerminal } from "@/features/terminal/TerminalTabContent";
|
||||||
TerminalTabContent,
|
|
||||||
loadTerminal,
|
|
||||||
} from "@/features/terminal/TerminalTabContent";
|
|
||||||
import TerminalApp from "@/features/terminal/TerminalApp";
|
import TerminalApp from "@/features/terminal/TerminalApp";
|
||||||
import {
|
import {
|
||||||
TERMINAL_DOCK_SLOT,
|
TERMINAL_DOCK_SLOT,
|
||||||
TERMINAL_OVERLAY_SLOT,
|
TERMINAL_OVERLAY_SLOT,
|
||||||
TERMINAL_TOOLBAR_SLOT,
|
TERMINAL_TOOLBAR_SLOT,
|
||||||
} from "@/features/terminal/terminal-slots";
|
} from "@/features/terminal/terminal-slots";
|
||||||
|
import { TerminalTabWithRegistry } from "./TerminalTabWithRegistry";
|
||||||
|
import { listSessions, sendToActive, sendToSession } from "./session-registry";
|
||||||
|
|
||||||
/** `?view=terminal` full-screen links. */
|
/** `?view=terminal` full-screen links. */
|
||||||
function TerminalStandalone({ hostId, params }: StandaloneViewProps) {
|
function TerminalStandalone({ hostId, params }: StandaloneViewProps) {
|
||||||
@@ -33,7 +32,7 @@ function TerminalStandalone({ hostId, params }: StandaloneViewProps) {
|
|||||||
export function activate(app: TermixApp): void {
|
export function activate(app: TermixApp): void {
|
||||||
app.registerTab(
|
app.registerTab(
|
||||||
"terminal",
|
"terminal",
|
||||||
TerminalTabContent as unknown as ComponentType<TabProps>,
|
TerminalTabWithRegistry as unknown as ComponentType<TabProps>,
|
||||||
{
|
{
|
||||||
icon: Terminal,
|
icon: Terminal,
|
||||||
titleKey: "nav.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_TOOLBAR_SLOT, accepts: ["button"] });
|
||||||
app.declareActionSlot({ id: TERMINAL_DOCK_SLOT, accepts: ["component"] });
|
app.declareActionSlot({ id: TERMINAL_DOCK_SLOT, accepts: ["component"] });
|
||||||
app.declareActionSlot({ id: TERMINAL_OVERLAY_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.settingsComponents()).toEqual([]);
|
||||||
expect(app.registered.actions()).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user