mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
This commit is contained in:
1 parent
d2ff5fc78f
commit
3b2c849514
59 files changed
+31807
-2977
No files matched your search
@@ -32,7 +32,8 @@
|
||||
"optionalDependencies": {
|
||||
"workspaces": "^1.0.0",
|
||||
"network-topology": "^1.0.0",
|
||||
"snippets": "^1.0.0"
|
||||
"snippets": "^1.0.0",
|
||||
"fleets": "^1.0.0"
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
@@ -49,6 +50,11 @@
|
||||
"service": "snippets.access",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"service": "fleets.access",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"providesSecret": [
|
||||
|
||||
@@ -52,6 +52,60 @@ export async function getNetworkTopology(
|
||||
}
|
||||
}
|
||||
|
||||
/** Another plugin's fleets, as ctx.services.get("fleets.access") returns them. */
|
||||
interface FleetsAccess {
|
||||
list: () => Promise<
|
||||
Array<{ id: number; name: string; color: string | null }>
|
||||
>;
|
||||
create: (input: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}) => Promise<{ id: number; name: string }>;
|
||||
addMember: (fleetId: number, hostId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's fleets, or null when the fleets plugin is off or the user may
|
||||
* not use it. Optional: the list_fleets tool disappears without it.
|
||||
*/
|
||||
export async function listFleets(
|
||||
userId: string,
|
||||
): Promise<Array<{ id: number; name: string; color: string | null }> | null> {
|
||||
if (!current) return null;
|
||||
try {
|
||||
return await current.get<FleetsAccess>("fleets.access", { userId }).list();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the fleets plugin, unlike listFleets above: a proposal apply must
|
||||
* surface a failure as an error the user sees, not silently no-op.
|
||||
*/
|
||||
function requireFleetsAccess(userId: string): FleetsAccess {
|
||||
if (!current) {
|
||||
throw new Error("The fleets plugin is disabled");
|
||||
}
|
||||
return current.get<FleetsAccess>("fleets.access", { userId });
|
||||
}
|
||||
|
||||
export async function createFleet(
|
||||
userId: string,
|
||||
input: { name: string; description?: string | null },
|
||||
): Promise<{ id: number; name: string } | null> {
|
||||
if (!current) return null;
|
||||
return requireFleetsAccess(userId).create(input);
|
||||
}
|
||||
|
||||
export async function addFleetMember(
|
||||
userId: string,
|
||||
fleetId: number,
|
||||
hostId: number,
|
||||
): Promise<void> {
|
||||
await requireFleetsAccess(userId).addMember(fleetId, hostId);
|
||||
}
|
||||
|
||||
export interface SnippetSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHostRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
||||
@@ -13,6 +12,8 @@ import {
|
||||
updateSnippet,
|
||||
deleteSnippet,
|
||||
getSnippet,
|
||||
createFleet,
|
||||
addFleetMember,
|
||||
} from "../services.js";
|
||||
|
||||
/** Approved commands get a bounded window rather than hanging the request. */
|
||||
@@ -175,10 +176,11 @@ export async function applyProposal(
|
||||
}
|
||||
|
||||
case "propose_create_fleet": {
|
||||
const fleet = await createCurrentFleetRepository().create(userId, {
|
||||
const fleet = await createFleet(userId, {
|
||||
name: requireString(payload.name, "name"),
|
||||
description: optionalString(payload.description),
|
||||
} as any);
|
||||
});
|
||||
if (!fleet) throw new Error("The fleets plugin is disabled");
|
||||
|
||||
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
|
||||
let added = 0;
|
||||
@@ -191,16 +193,13 @@ export async function applyProposal(
|
||||
hostId,
|
||||
);
|
||||
if (!host) continue;
|
||||
await createCurrentFleetRepository().addMember(
|
||||
(fleet as any).id,
|
||||
hostId,
|
||||
);
|
||||
await addFleetMember(userId, fleet.id, hostId);
|
||||
added += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created fleet ${(fleet as any).name} with ${added} host${added === 1 ? "" : "s"}`,
|
||||
summary: `Created fleet ${fleet.name} with ${added} host${added === 1 ? "" : "s"}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentHostRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
listSavedWorkspaces,
|
||||
getNetworkTopology,
|
||||
listSnippets,
|
||||
listFleets,
|
||||
} from "../services.js";
|
||||
|
||||
/**
|
||||
@@ -87,18 +87,16 @@ export const readTools: AiTool[] = [
|
||||
{
|
||||
name: "list_fleets",
|
||||
description:
|
||||
"List the user's fleets. Fleets group hosts for bulk operations and inventory.",
|
||||
"List the user's fleets. Fleets group hosts for bulk operations and inventory. Unavailable if the fleets plugin is disabled.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const fleets = await createCurrentFleetRepository().listByUser(
|
||||
context.userId,
|
||||
);
|
||||
const fleets = await listFleets(context.userId);
|
||||
if (fleets === null) return { unavailable: true, fleets: [] };
|
||||
return {
|
||||
fleets: fleets.map((fleet: any) => ({
|
||||
fleets: fleets.map((fleet) => ({
|
||||
id: fleet.id,
|
||||
name: fleet.name,
|
||||
description: fleet.description ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -12,13 +12,12 @@ const snippetRepository = {
|
||||
updateSnippet: vi.fn(),
|
||||
deleteSnippet: vi.fn(),
|
||||
};
|
||||
const fleetRepository = { create: vi.fn(), addMember: vi.fn() };
|
||||
const automationRepository = { create: vi.fn() };
|
||||
const fleetsAccess = { create: vi.fn(), addMember: vi.fn() };
|
||||
|
||||
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => hostRepository,
|
||||
createCurrentSnippetRepository: () => snippetRepository,
|
||||
createCurrentFleetRepository: () => fleetRepository,
|
||||
createCurrentAutomationRepository: () => automationRepository,
|
||||
}));
|
||||
|
||||
@@ -44,10 +43,12 @@ vi.mock("../../../src/backend/ssh.js", () => ({
|
||||
|
||||
const { applyProposal } =
|
||||
await import("../../../src/backend/tools/executor.js");
|
||||
const { setPluginServices } = await import("../../../src/backend/services.js");
|
||||
|
||||
describe("applyProposal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setPluginServices(null);
|
||||
});
|
||||
|
||||
it("refuses a kind that is not a real tool", async () => {
|
||||
@@ -221,7 +222,15 @@ describe("applyProposal", () => {
|
||||
});
|
||||
|
||||
it("only adds fleet members the approving user owns", async () => {
|
||||
fleetRepository.create.mockResolvedValue({ id: 3, name: "prod" });
|
||||
setPluginServices({
|
||||
get: (service: string) => {
|
||||
if (service !== "fleets.access")
|
||||
throw new Error(`unexpected ${service}`);
|
||||
return fleetsAccess as never;
|
||||
},
|
||||
provide: vi.fn(),
|
||||
} as never);
|
||||
fleetsAccess.create.mockResolvedValue({ id: 3, name: "prod" });
|
||||
hostRepository.findByIdForUser.mockImplementation(
|
||||
async (_userId: string, hostId: number) =>
|
||||
hostId === 1 ? { id: 1 } : null,
|
||||
@@ -233,11 +242,17 @@ describe("applyProposal", () => {
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(fleetRepository.addMember).toHaveBeenCalledTimes(1);
|
||||
expect(fleetRepository.addMember).toHaveBeenCalledWith(3, 1);
|
||||
expect(fleetsAccess.addMember).toHaveBeenCalledTimes(1);
|
||||
expect(fleetsAccess.addMember).toHaveBeenCalledWith(3, 1);
|
||||
expect(result.summary).toContain("1 host");
|
||||
});
|
||||
|
||||
it("fails clearly when the fleets plugin is disabled", async () => {
|
||||
await expect(
|
||||
applyProposal("propose_create_fleet", { name: "prod" }, "user-1"),
|
||||
).rejects.toThrow("fleets plugin is disabled");
|
||||
});
|
||||
|
||||
it("reports nothing to change on an empty update", async () => {
|
||||
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
|
||||
|
||||
|
||||
Reference in new issue
Block a user