feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

This commit is contained in:
LukeGus committed 2026-09-23 11:24:16 -05:00
1 parent d2ff5fc78f
commit 3b2c849514
59 files changed
+31807 -2977

No files matched your search

+7 -1
View File
@@ -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": [
+54
View File
@@ -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;
+7 -8
View File
@@ -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"}`,
};
}
+5 -7
View File
@@ -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 });
+7 -1
View File
@@ -27,13 +27,19 @@
"ui:surface"
],
"optionalDependencies": {
"snippets": "^1.0.0"
"snippets": "^1.0.0",
"fleets": "^1.0.0"
},
"requires": [
{
"service": "snippets.access",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "fleets.access",
"versionRange": "^1.0.0",
"optional": true
}
],
"contributes": {
@@ -1,7 +1,7 @@
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 { fleetMemberIds } from "../snippets.js";
import type { StepExecutionContext } from "./types.js";
/** A host the caller is allowed to act on. `host` is always resolved. */
@@ -63,13 +63,7 @@ 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 [];
}
return fleetMemberIds(userId, fleetId);
}
async function allAccessibleHostIds(userId: string): Promise<number[]> {
@@ -62,3 +62,30 @@ export async function getSnippet(
return null;
}
}
/** Another plugin's fleets, as ctx.services.get("fleets.access") returns them. */
interface FleetsAccess {
members: (
fleetId: number,
) => Promise<Array<{ id: number; name: string | null; ip: string }>>;
}
/**
* A fleet's effective member host ids, for a "fleet" target selector. Empty
* when the fleets plugin is off, the user may not use it, or the fleet does
* not exist - the step then runs against no hosts rather than failing.
*/
export async function fleetMemberIds(
userId: string,
fleetId: number,
): Promise<number[]> {
if (!current) return [];
try {
const members = await current
.get<FleetsAccess>("fleets.access", { userId })
.members(fleetId);
return members.map((member) => member.id);
} catch {
return [];
}
}
+14
View File
@@ -1,4 +1,18 @@
{
"permissions": {
"view": {
"title": "View fleets",
"description": "See fleets, their members and inventory."
},
"manage": {
"title": "Manage fleets",
"description": "Create, edit and delete fleets, change membership, and share fleets with other users or roles."
},
"execute": {
"title": "Run fleet actions",
"description": "Run commands and package actions across a fleet, and transfer files to and from its member hosts."
}
},
"newUi": {
"sidebar": {
"fleets": {
+28
View File
@@ -16,12 +16,20 @@
},
"capabilities": [
"hosts:read",
"hosts:write",
"credentials:use",
"ssh:connect",
"db:own",
"network:serve",
"ui:surface"
],
"provides": [
{
"service": "fleets.access",
"version": "1.0.0",
"permission": "fleets.view"
}
],
"locales": "locales",
"platforms": ["linux", "win32", "darwin"],
"contributes": {
@@ -39,6 +47,26 @@
"titleKey": "nav.fleets",
"icon": "Boxes"
}
],
"permissions": [
{
"name": "view",
"titleKey": "permissions.view.title",
"descriptionKey": "permissions.view.description",
"defaultRoles": ["user"]
},
{
"name": "manage",
"titleKey": "permissions.manage.title",
"descriptionKey": "permissions.manage.description",
"defaultRoles": ["user"]
},
{
"name": "execute",
"titleKey": "permissions.execute.title",
"descriptionKey": "permissions.execute.description",
"defaultRoles": ["user"]
}
]
}
}
@@ -0,0 +1,42 @@
-- fleets 0001: adopt_fleets
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `fleets` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`description` text,
`color` text,
`icon` text,
`tag_rules` text,
`sync_id` varchar(255) UNIQUE,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `fleets` TO `p_fleets_fleets`;
CREATE TABLE IF NOT EXISTS `fleet_members` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`fleet_id` int NOT NULL,
`host_id` int NOT NULL,
`added_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`fleet_id`) REFERENCES `p_fleets_fleets` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
RENAME TABLE `fleet_members` TO `p_fleets_members`;
CREATE TABLE IF NOT EXISTS `fleet_inventory` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`host_id` int NOT NULL,
`user_id` varchar(255) NOT NULL,
`os_pretty_name` text,
`kernel` text,
`architecture` text,
`hostname` text,
`uptime_seconds` int,
`ip` text,
`package_manager` text,
`collected_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `fleet_inventory` TO `p_fleets_inventory`;
@@ -0,0 +1,46 @@
-- fleets 0001: adopt_fleets
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "fleets" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"description" text,
"color" text,
"icon" text,
"tag_rules" text,
"sync_id" varchar(255) UNIQUE,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";
CREATE TABLE IF NOT EXISTS "fleet_members" (
"id" serial PRIMARY KEY,
"fleet_id" integer NOT NULL,
"host_id" integer NOT NULL,
"added_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("fleet_id") REFERENCES "p_fleets_fleets" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_members_fleet_host" ON "fleet_members" ("fleet_id", "host_id");
CREATE INDEX IF NOT EXISTS "idx_fleet_members_host" ON "fleet_members" ("host_id");
ALTER TABLE "fleet_members" RENAME TO "p_fleets_members";
CREATE TABLE IF NOT EXISTS "fleet_inventory" (
"id" serial PRIMARY KEY,
"host_id" integer NOT NULL,
"user_id" varchar(255) NOT NULL,
"os_pretty_name" text,
"kernel" text,
"architecture" text,
"hostname" text,
"uptime_seconds" integer,
"ip" text,
"package_manager" text,
"collected_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_inventory_host" ON "fleet_inventory" ("host_id", "user_id");
CREATE INDEX IF NOT EXISTS "idx_fleet_inventory_user" ON "fleet_inventory" ("user_id");
ALTER TABLE "fleet_inventory" RENAME TO "p_fleets_inventory";
+141
View File
@@ -0,0 +1,141 @@
{
"version": 1,
"tables": {
"fleets": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"name": {
"type": "text",
"notNull": true
},
"description": {
"type": "text"
},
"color": {
"type": "text"
},
"icon": {
"type": "text"
},
"tagRules": {
"type": "text"
},
"syncId": {
"type": "text",
"unique": true
},
"createdAt": {
"type": "text",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "text",
"notNull": true,
"defaultNow": true
}
},
"indexes": [],
"adopts": "fleets"
},
"members": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"fleetId": {
"type": "integer",
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"addedAt": {
"type": "text",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_fleet_members_fleet_host",
"columns": ["fleetId", "hostId"],
"unique": true
},
{
"name": "idx_fleet_members_host",
"columns": ["hostId"]
}
],
"adopts": "fleet_members"
},
"inventory": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"osPrettyName": {
"type": "text"
},
"kernel": {
"type": "text"
},
"architecture": {
"type": "text"
},
"hostname": {
"type": "text"
},
"uptimeSeconds": {
"type": "integer"
},
"ip": {
"type": "text"
},
"packageManager": {
"type": "text"
},
"collectedAt": {
"type": "text",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_fleet_inventory_host",
"columns": ["hostId", "userId"],
"unique": true
},
{
"name": "idx_fleet_inventory_user",
"columns": ["userId"]
}
],
"adopts": "fleet_inventory"
}
}
}
@@ -0,0 +1,46 @@
-- fleets 0001: adopt_fleets
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "fleets" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"color" text,
"icon" text,
"tag_rules" text,
"sync_id" text UNIQUE,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";
CREATE TABLE IF NOT EXISTS "fleet_members" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"fleet_id" integer NOT NULL,
"host_id" integer NOT NULL,
"added_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("fleet_id") REFERENCES "p_fleets_fleets" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_members_fleet_host" ON "fleet_members" ("fleet_id", "host_id");
CREATE INDEX IF NOT EXISTS "idx_fleet_members_host" ON "fleet_members" ("host_id");
ALTER TABLE "fleet_members" RENAME TO "p_fleets_members";
CREATE TABLE IF NOT EXISTS "fleet_inventory" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"host_id" integer NOT NULL,
"user_id" text NOT NULL,
"os_pretty_name" text,
"kernel" text,
"architecture" text,
"hostname" text,
"uptime_seconds" integer,
"ip" text,
"package_manager" text,
"collected_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_inventory_host" ON "fleet_inventory" ("host_id", "user_id");
CREATE INDEX IF NOT EXISTS "idx_fleet_inventory_user" ON "fleet_inventory" ("user_id");
ALTER TABLE "fleet_inventory" RENAME TO "p_fleets_inventory";
+67 -6
View File
@@ -1,14 +1,75 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { startFleetsService, stopFleetsService } from "./routes.js";
import { fleets, fleetMembers, fleetInventory } from "./tables.js";
import { createFleetRepository } from "./repository.js";
import { registerFleetRoutes } from "./routes.js";
/** What other plugins get from ctx.services.get("fleets.access", {userId}). */
export interface FleetsService {
/** The calling user's fleets, without resolving membership. */
list: () => Promise<
Array<{ id: number; name: string; color: string | null }>
>;
/** A fleet's effective member hosts (static membership union tag matches). */
members: (
fleetId: number,
) => Promise<Array<{ id: number; name: string | null; ip: string }>>;
/** Creates a fleet owned by the calling user. */
create: (input: {
name: string;
description?: string | null;
}) => Promise<{ id: number; name: string }>;
/** Adds a host the calling user owns to a fleet's static membership. */
addMember: (fleetId: number, hostId: number) => Promise<void>;
}
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
startFleetsService(ctx.http.router());
const fleetsTable = await ctx.db.define(fleets);
const membersTable = await ctx.db.define(fleetMembers);
const inventoryTable = await ctx.db.define(fleetInventory);
const repo = createFleetRepository(
ctx.db,
ctx.hosts,
fleetsTable,
membersTable,
inventoryTable,
);
registerFleetRoutes(ctx.http.router<Router>(), repo, ctx);
const service: FleetsService = {
list: async () => {
const userId = ctx.currentActor();
if (!userId) return [];
const rows = await repo.listByUser(userId);
return rows.map((f) => ({ id: f.id, name: f.name, color: f.color }));
},
members: async (fleetId) => {
const userId = ctx.currentActor();
if (!userId) return [];
const members = await repo.listEffectiveMembers(userId, fleetId);
return members.map((m) => ({ id: m.id, name: m.name, ip: m.ip }));
},
create: async (input) => {
const userId = ctx.currentActor();
if (!userId) throw new Error("fleets.access.create needs an actor");
const created = await repo.create(userId, input);
return { id: created.id, name: created.name };
},
addMember: async (fleetId, hostId) => {
const userId = ctx.currentActor();
if (!userId) throw new Error("fleets.access.addMember needs an actor");
const fleet = await repo.findById(userId, fleetId);
if (!fleet) throw new Error("Fleet not found");
await repo.addMember(fleetId, hostId);
},
};
ctx.services.provide("fleets.access", service);
ctx.log.info("Fleets routes mounted at /plugin-api/fleets");
}
export async function deactivate() {
stopFleetsService();
// Everything above was registered through ctx and is disposed by core.
}
+370
View File
@@ -0,0 +1,370 @@
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import type { PluginDatabase, PluginHosts } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// The tables come from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface FleetRecord {
id: number;
userId: string;
name: string;
description: string | null;
color: string | null;
icon: string | null;
tagRules: string | null;
syncId: string | null;
createdAt: string;
updatedAt: string;
}
export interface FleetCreateInput {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}
export interface FleetUpdateInput {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}
export interface FleetInventoryRecord {
id: number;
hostId: number;
userId: string;
osPrettyName: string | null;
kernel: string | null;
architecture: string | null;
hostname: string | null;
uptimeSeconds: number | null;
ip: string | null;
packageManager: string | null;
collectedAt: string;
}
export interface FleetInventoryInput {
osPrettyName: string | null;
kernel: string | null;
architecture: string | null;
hostname: string | null;
uptimeSeconds: number | null;
ip: string | null;
packageManager: string | null;
}
function parseTagRules(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((t): t is string => typeof t === "string")
: [];
} catch {
return [];
}
}
export type FleetRepository = ReturnType<typeof createFleetRepository>;
/**
* Fleets, membership and inventory, written without RETURNING so it runs the
* same on all three engines: an insert is read back by its sync id, an
* update by its own id.
*/
export function createFleetRepository(
db: PluginDatabase,
hosts: PluginHosts,
fleetsTable: Table,
membersTable: Table,
inventoryTable: Table,
) {
const client = () => db.client<Drizzle>();
async function findById(
userId: string,
fleetId: number,
): Promise<FleetRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(fleetsTable)
.where(and(eq(fleetsTable.id, fleetId), eq(fleetsTable.userId, userId)))
.limit(1);
return (rows[0] as FleetRecord) ?? null;
}
async function findBySyncId(syncId: string): Promise<FleetRecord> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(fleetsTable)
.where(eq(fleetsTable.syncId, syncId))
.limit(1);
return rows[0] as FleetRecord;
}
async function listStaticMemberIds(fleetId: number): Promise<number[]> {
const drizzle = await client();
const rows = await drizzle
.select({ hostId: membersTable.hostId })
.from(membersTable)
.where(eq(membersTable.fleetId, fleetId));
return rows.map((r: { hostId: number }) => r.hostId);
}
return {
async listByUser(userId: string): Promise<FleetRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(fleetsTable)
.where(eq(fleetsTable.userId, userId));
},
findById,
listStaticMemberIds,
async create(
userId: string,
input: FleetCreateInput,
now = new Date().toISOString(),
): Promise<FleetRecord> {
const syncId = randomUUID();
const drizzle = await client();
await drizzle.insert(fleetsTable).values({
userId,
name: input.name,
description: input.description ?? null,
color: input.color ?? null,
icon: input.icon ?? null,
tagRules: input.tagRules ? JSON.stringify(input.tagRules) : null,
syncId,
createdAt: now,
updatedAt: now,
});
await db.persist();
return findBySyncId(syncId);
},
async update(
userId: string,
fleetId: number,
input: FleetUpdateInput,
now = new Date().toISOString(),
): Promise<FleetRecord | null> {
const existing = await findById(userId, fleetId);
if (!existing) return null;
const drizzle = await client();
await drizzle
.update(fleetsTable)
.set({
name: input.name ?? existing.name,
description:
input.description === undefined
? existing.description
: input.description,
color: input.color === undefined ? existing.color : input.color,
icon: input.icon === undefined ? existing.icon : input.icon,
tagRules:
input.tagRules === undefined
? existing.tagRules
: JSON.stringify(input.tagRules),
updatedAt: now,
})
.where(
and(eq(fleetsTable.id, fleetId), eq(fleetsTable.userId, userId)),
);
await db.persist();
return findById(userId, fleetId);
},
async delete(userId: string, fleetId: number): Promise<boolean> {
const existing = await findById(userId, fleetId);
if (!existing) return false;
const drizzle = await client();
await drizzle
.delete(fleetsTable)
.where(
and(eq(fleetsTable.id, fleetId), eq(fleetsTable.userId, userId)),
);
await db.persist();
return true;
},
async addMember(
fleetId: number,
hostId: number,
now = new Date().toISOString(),
): Promise<void> {
const drizzle = await client();
const existing = await drizzle
.select({ id: membersTable.id })
.from(membersTable)
.where(
and(
eq(membersTable.fleetId, fleetId),
eq(membersTable.hostId, hostId),
),
)
.limit(1);
if (existing.length > 0) return;
await drizzle
.insert(membersTable)
.values({ fleetId, hostId, addedAt: now });
await db.persist();
},
async removeMember(fleetId: number, hostId: number): Promise<boolean> {
const drizzle = await client();
const existing = await drizzle
.select({ id: membersTable.id })
.from(membersTable)
.where(
and(
eq(membersTable.fleetId, fleetId),
eq(membersTable.hostId, hostId),
),
)
.limit(1);
if (existing.length === 0) return false;
await drizzle
.delete(membersTable)
.where(
and(
eq(membersTable.fleetId, fleetId),
eq(membersTable.hostId, hostId),
),
);
await db.persist();
return true;
},
/**
* Effective membership = static members rows union hosts whose
* comma-separated tags string intersects any tag in the fleet's
* tagRules. Both sets are scoped to hosts the fleet owner can see
* (ctx.hosts.list already resolves owner-only, since a fleet only holds
* one user's hosts) and deduplicated by id.
*/
async listEffectiveMembers(userId: string, fleetId: number) {
const fleet = await findById(userId, fleetId);
if (!fleet) return [];
const staticIds = await listStaticMemberIds(fleetId);
const tagRules = parseTagRules(fleet.tagRules);
const ownedHosts = (await hosts.list()).filter(
(host) => host.userId === userId,
);
const byId = new Map<number, (typeof ownedHosts)[number]>();
for (const host of ownedHosts) {
if (staticIds.includes(host.id)) {
byId.set(host.id, host);
continue;
}
if (tagRules.length === 0) continue;
const hostTags = (host.tags ?? "")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
if (hostTags.some((tag) => tagRules.includes(tag))) {
byId.set(host.id, host);
}
}
return Array.from(byId.values());
},
async deleteByUserId(userId: string): Promise<number> {
const drizzle = await client();
const userFleets = await drizzle
.select({ id: fleetsTable.id })
.from(fleetsTable)
.where(eq(fleetsTable.userId, userId));
if (userFleets.length === 0) return 0;
await drizzle.delete(fleetsTable).where(eq(fleetsTable.userId, userId));
await db.persist();
return userFleets.length;
},
async upsertInventory(
userId: string,
hostId: number,
input: FleetInventoryInput,
now = new Date().toISOString(),
): Promise<FleetInventoryRecord> {
const drizzle = await client();
const existing = await drizzle
.select()
.from(inventoryTable)
.where(
and(
eq(inventoryTable.hostId, hostId),
eq(inventoryTable.userId, userId),
),
)
.limit(1);
if (existing[0]) {
await drizzle
.update(inventoryTable)
.set({ ...input, collectedAt: now })
.where(eq(inventoryTable.id, existing[0].id));
await db.persist();
const [updated] = await drizzle
.select()
.from(inventoryTable)
.where(eq(inventoryTable.id, existing[0].id))
.limit(1);
return updated as FleetInventoryRecord;
}
await drizzle
.insert(inventoryTable)
.values({ userId, hostId, ...input, collectedAt: now });
await db.persist();
const [created] = await drizzle
.select()
.from(inventoryTable)
.where(
and(
eq(inventoryTable.hostId, hostId),
eq(inventoryTable.userId, userId),
),
)
.limit(1);
return created as FleetInventoryRecord;
},
async listInventoryForHosts(
userId: string,
hostIds: number[],
): Promise<FleetInventoryRecord[]> {
if (hostIds.length === 0) return [];
const drizzle = await client();
return drizzle
.select()
.from(inventoryTable)
.where(
and(
eq(inventoryTable.userId, userId),
inArray(inventoryTable.hostId, hostIds),
),
);
},
};
}
File diff suppressed because it is too large. Load diff
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+98
View File
@@ -0,0 +1,98 @@
import {
adoptLegacyTable,
defineTable,
id,
integer,
refHost,
refUser,
text,
} from "@termix/plugin-sdk/db";
/**
* A fleet: a named group of hosts, by static membership or tag rules.
*
* Adopted from core's "fleets", so column and index names are the legacy
* ones: the rename carries the existing rows across.
*/
export const fleets = adoptLegacyTable(
"fleets",
defineTable("fleets", {
id: id(),
userId: refUser(),
name: text().notNull(),
description: text(),
color: text(),
icon: text(),
// JSON array of tag strings, unioned with static members at resolution
// time. Kept to tag-equality matching for v1.
tagRules: text(),
syncId: text().unique(),
createdAt: text().notNull().defaultNow(),
updatedAt: text().notNull().defaultNow(),
}),
);
/**
* Static fleet membership. A host can also be an effective member through a
* tag rule match, resolved in the repository rather than stored here.
*
* fleetId has no declared refUser/refHost FK: the SDK's referenceable targets
* are core's users and ssh_data tables only, not another table this same
* plugin owns. The legacy foreign key to fleets(id) is preserved by the
* adoption migration's CREATE TABLE, which is why the migration file keeps
* the FOREIGN KEY clause the generator does not emit for this column.
*/
export const fleetMembers = adoptLegacyTable(
"fleet_members",
defineTable(
"members",
{
id: id(),
fleetId: integer().notNull(),
hostId: refHost(),
addedAt: text().notNull().defaultNow(),
},
{
// fleetId leads the unique pair, so listing a fleet's hosts is already
// served. Finding the fleets a host belongs to starts from hostId.
uniques: [
{
name: "idx_fleet_members_fleet_host",
columns: ["fleetId", "hostId"],
},
],
indexes: [{ name: "idx_fleet_members_host", columns: ["hostId"] }],
},
),
);
// Latest-only inventory snapshot per host, overwritten on each refresh - no
// historical log, matching the "latest snapshot only" scope decision.
export const fleetInventory = adoptLegacyTable(
"fleet_inventory",
defineTable(
"inventory",
{
id: id(),
hostId: refHost(),
userId: refUser(),
osPrettyName: text(),
kernel: text(),
architecture: text(),
hostname: text(),
uptimeSeconds: integer(),
ip: text(),
packageManager: text(),
collectedAt: text().notNull().defaultNow(),
},
{
// hostId leads the unique pair; a user's whole inventory reads by userId.
uniques: [
{ name: "idx_fleet_inventory_host", columns: ["hostId", "userId"] },
],
indexes: [{ name: "idx_fleet_inventory_user", columns: ["userId"] }],
},
),
);
export const tables = [fleets, fleetMembers, fleetInventory];
@@ -1,6 +1,7 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage, cn } from "./helpers.js";
import { useEffect, useMemo, useState, useCallback } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Input, Button } from "@termix/plugin-sdk/ui";
import { toast } from "sonner";
import {
ArrowDown,
@@ -11,16 +12,7 @@ import {
Search,
Server,
} from "lucide-react";
import { Input } from "@/components/input";
import { Button } from "@/components/button";
import { cn } from "@/lib/utils";
import {
listFleets,
getFleetInventory,
refreshFleetInventory,
type FleetRow,
type FleetInventoryEntry,
} from "./fleets-api";
import type { FleetsApi, FleetRow, FleetInventoryEntry } from "./fleets-api.js";
type SortKey =
| "hostName"
@@ -90,9 +82,11 @@ function SortHeader({
}
export function FleetInventoryTab({
api,
fleetId,
isVisible = true,
}: {
api: FleetsApi;
fleetId?: number;
isVisible?: boolean;
}) {
@@ -113,10 +107,11 @@ export function FleetInventoryTab({
}, [fleetId]);
useEffect(() => {
listFleets()
api
.list()
.then(setFleets)
.catch(() => {});
}, []);
}, [api]);
useEffect(() => {
if (!fleets.length) return;
@@ -129,7 +124,7 @@ export function FleetInventoryTab({
if (selectedFleetId === undefined) return;
setLoading(true);
try {
const data = await getFleetInventory(selectedFleetId);
const data = await api.inventory(selectedFleetId);
setEntries(data);
} catch (error) {
const message = getErrorMessage(error, "");
@@ -137,7 +132,7 @@ export function FleetInventoryTab({
} finally {
setLoading(false);
}
}, [selectedFleetId, t]);
}, [api, selectedFleetId, t]);
useEffect(() => {
if (!isVisible) return;
@@ -148,7 +143,7 @@ export function FleetInventoryTab({
if (selectedFleetId === undefined) return;
setRefreshing(true);
try {
const { results } = await refreshFleetInventory(selectedFleetId);
const { results } = await api.refreshInventory(selectedFleetId);
const failed = results.filter((r) => !r.success).length;
if (failed > 0) {
toast.warning(
+108 -119
View File
@@ -1,6 +1,33 @@
import { getErrorMessage } from "@/lib/error-message.js";
import {
getErrorMessage,
extractSnippetInputs,
type SnippetInput,
} from "./helpers.js";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
useTranslation,
useHosts,
type PluginHostRecord,
} from "@termix/plugin-sdk/frontend";
import {
Button,
Input,
Textarea,
Checkbox,
Badge,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
FOLDER_COLORS,
Select2,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@termix/plugin-sdk/ui";
import { toast } from "sonner";
import {
Boxes,
@@ -23,61 +50,19 @@ import {
User,
X,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Textarea } from "@/components/textarea";
import { Checkbox } from "@/components/checkbox";
import { Badge } from "@/components/badge";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import { FOLDER_COLORS } from "@/lib/theme";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { getUserList } from "@/api/user-management-api";
import {
getRoles,
type SharePermissionLevel,
type ShareTarget,
} from "@/api/rbac-api";
import { Select2 } from "@/components/select2";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import {
extractSnippetInputs,
type SnippetInput,
} from "@/lib/snippet-variables";
import {
listFleets,
createFleet,
updateFleet,
deleteFleet,
getFleetMembers,
addFleetMember,
removeFleetMember,
runFleetCommand,
pushFleetFile,
pullFleetFile,
getFleetInventory,
refreshFleetInventory,
runFleetPackageAction,
shareFleet,
type FleetRow,
type FleetMemberRow,
type FleetHostResult,
type FleetInventoryEntry,
type FleetPackageAction,
type FleetShareHostResult,
} from "./fleets-api";
import type { SSHHost } from "@/types/index";
import type {
FleetsApi,
FleetRow,
FleetMemberRow,
FleetHostResult,
FleetInventoryEntry,
FleetPackageAction,
FleetShareHostResult,
ShareableUser,
ShareableRole,
SharePermissionLevel,
ShareTarget,
} from "./fleets-api.js";
const SHARE_PERMISSION_LEVELS: SharePermissionLevel[] = [
"connect",
@@ -144,11 +129,13 @@ function ResultsList({ results }: { results: FleetHostResult[] }) {
}
function FleetFormDialog({
api,
open,
onClose,
fleet,
onSaved,
}: {
api: FleetsApi;
open: boolean;
onClose: () => void;
fleet: FleetRow | null;
@@ -182,7 +169,7 @@ function FleetFormDialog({
setSaving(true);
try {
if (fleet) {
await updateFleet(fleet.id, {
await api.update(fleet.id, {
name: name.trim(),
description: description.trim() || null,
color,
@@ -190,7 +177,7 @@ function FleetFormDialog({
});
toast.success(t("newUi.sidebar.fleets.fleetUpdated"));
} else {
await createFleet({
await api.create({
name: name.trim(),
description: description.trim() || null,
color,
@@ -304,6 +291,7 @@ function FleetFormDialog({
}
function MemberPickerDialog({
api,
open,
onClose,
fleetId,
@@ -311,10 +299,11 @@ function MemberPickerDialog({
memberIds,
onChanged,
}: {
api: FleetsApi;
open: boolean;
onClose: () => void;
fleetId: number;
allHosts: SSHHost[];
allHosts: PluginHostRecord[];
memberIds: Set<number>;
onChanged: () => void;
}) {
@@ -330,13 +319,14 @@ function MemberPickerDialog({
[allHosts, search],
);
async function toggleHost(host: SSHHost, isMember: boolean) {
setBusyId(host.id);
async function toggleHost(host: PluginHostRecord, isMember: boolean) {
const hostId = Number(host.id);
setBusyId(hostId);
try {
if (isMember) {
await removeFleetMember(fleetId, host.id);
await api.removeMember(fleetId, hostId);
} else {
await addFleetMember(fleetId, host.id);
await api.addMember(fleetId, hostId);
}
onChanged();
} catch (error) {
@@ -371,7 +361,8 @@ function MemberPickerDialog({
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
{visibleHosts.map((host) => {
const isMember = memberIds.has(host.id);
const hostId = Number(host.id);
const isMember = memberIds.has(hostId);
return (
<label
key={host.id}
@@ -379,7 +370,7 @@ function MemberPickerDialog({
>
<Checkbox
checked={isMember}
disabled={busyId === host.id}
disabled={busyId === hostId}
onCheckedChange={() => toggleHost(host, isMember)}
/>
<span className="truncate flex-1">{host.name}</span>
@@ -411,10 +402,12 @@ function MemberPickerDialog({
}
function FleetShareDialog({
api,
open,
onClose,
fleet,
}: {
api: FleetsApi;
open: boolean;
onClose: () => void;
fleet: FleetRow | null;
@@ -422,12 +415,8 @@ function FleetShareDialog({
const { t } = useTranslation();
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState("");
const [shareUsers, setShareUsers] = useState<
{ id: string; username: string }[]
>([]);
const [shareRoles, setShareRoles] = useState<
{ id: number; name: string; displayName?: string }[]
>([]);
const [shareUsers, setShareUsers] = useState<ShareableUser[]>([]);
const [shareRoles, setShareRoles] = useState<ShareableRole[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
new Set(),
);
@@ -457,26 +446,13 @@ function FleetShareDialog({
setTargetTab("user");
setSummary(null);
Promise.all([
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
api.shareTargetUsers().catch(() => ({ users: [] })),
api.shareTargetRoles().catch(() => ({ roles: [] })),
]).then(([usersRes, rolesRes]) => {
setShareUsers(
(usersRes.users ?? []).map((u) => ({
id: String(u.userId),
username: u.username,
})),
);
setShareRoles(
(rolesRes.roles ?? [])
.filter((r) => !r.isSystem)
.map((r) => ({
id: Number(r.id),
name: r.name,
displayName: r.displayName,
})),
);
setShareUsers(usersRes.users ?? []);
setShareRoles(rolesRes.roles ?? []);
});
}, [open]);
}, [open, api]);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
@@ -523,7 +499,7 @@ function FleetShareDialog({
setSubmitting(true);
try {
const result = await shareFleet(fleet.id, {
const result = await api.share(fleet.id, {
targets,
permissionLevel,
...(durationHours ? { durationHours } : {}),
@@ -808,7 +784,7 @@ function FleetShareDialog({
);
}
function RunCommandTab({ fleetId }: { fleetId: number }) {
function RunCommandTab({ api, fleetId }: { api: FleetsApi; fleetId: number }) {
const { t } = useTranslation();
const [command, setCommand] = useState("");
const [inputs, setInputs] = useState<SnippetInput[]>([]);
@@ -828,7 +804,7 @@ function RunCommandTab({ fleetId }: { fleetId: number }) {
setRunning(true);
setResults(null);
try {
const { results: r } = await runFleetCommand(
const { results: r } = await api.runCommand(
fleetId,
command,
inputs.length > 0 ? inputValues : undefined,
@@ -901,7 +877,7 @@ function RunCommandTab({ fleetId }: { fleetId: number }) {
);
}
function TransferTab({ fleetId }: { fleetId: number }) {
function TransferTab({ api, fleetId }: { api: FleetsApi; fleetId: number }) {
const { t } = useTranslation();
const [direction, setDirection] = useState<"push" | "pull">("push");
const [remotePath, setRemotePath] = useState("");
@@ -923,14 +899,14 @@ function TransferTab({ fleetId }: { fleetId: number }) {
setResults(null);
try {
if (direction === "push" && file) {
const { results: r } = await pushFleetFile(fleetId, file, remotePath);
const { results: r } = await api.pushFile(fleetId, file, remotePath);
setResults(r);
} else {
const {
results: r,
blob,
fileName,
} = await pullFleetFile(fleetId, remotePath);
} = await api.pullFile(fleetId, remotePath);
setResults(r);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -1018,7 +994,7 @@ function TransferTab({ fleetId }: { fleetId: number }) {
);
}
function InventoryTab({ fleetId }: { fleetId: number }) {
function InventoryTab({ api, fleetId }: { api: FleetsApi; fleetId: number }) {
const { t } = useTranslation();
const [entries, setEntries] = useState<FleetInventoryEntry[]>([]);
const [loading, setLoading] = useState(true);
@@ -1027,7 +1003,7 @@ function InventoryTab({ fleetId }: { fleetId: number }) {
const load = useCallback(async () => {
setLoading(true);
try {
const data = await getFleetInventory(fleetId);
const data = await api.inventory(fleetId);
setEntries(data);
} catch (error) {
const message = getErrorMessage(error, "");
@@ -1035,7 +1011,7 @@ function InventoryTab({ fleetId }: { fleetId: number }) {
} finally {
setLoading(false);
}
}, [fleetId, t]);
}, [api, fleetId, t]);
useEffect(() => {
load();
@@ -1044,7 +1020,7 @@ function InventoryTab({ fleetId }: { fleetId: number }) {
async function handleRefresh() {
setRefreshing(true);
try {
const { results } = await refreshFleetInventory(fleetId);
const { results } = await api.refreshInventory(fleetId);
const failed = results.filter((r) => !r.success).length;
if (failed > 0) {
toast.warning(
@@ -1137,7 +1113,7 @@ function InventoryTab({ fleetId }: { fleetId: number }) {
);
}
function PackagesTab({ fleetId }: { fleetId: number }) {
function PackagesTab({ api, fleetId }: { api: FleetsApi; fleetId: number }) {
const { t } = useTranslation();
const [action, setAction] = useState<FleetPackageAction>("install");
const [packageName, setPackageName] = useState("");
@@ -1152,7 +1128,7 @@ function PackagesTab({ fleetId }: { fleetId: number }) {
setRunning(true);
setResults(null);
try {
const { results: r } = await runFleetPackageAction(
const { results: r } = await api.runPackageAction(
fleetId,
action,
action === "upgrade-all" ? undefined : packageName.trim(),
@@ -1240,14 +1216,16 @@ function PackagesTab({ fleetId }: { fleetId: number }) {
type FleetActionView = "run" | "transfer" | "inventory" | "packages";
function FleetDetail({
api,
fleet,
allHosts,
onBack,
onFleetChanged,
onOpenFleetInventory,
}: {
api: FleetsApi;
fleet: FleetRow;
allHosts: SSHHost[];
allHosts: PluginHostRecord[];
onBack: () => void;
onFleetChanged: () => void;
onOpenFleetInventory?: (fleetId: number) => void;
@@ -1262,7 +1240,7 @@ function FleetDetail({
const loadMembers = useCallback(async () => {
setLoadingMembers(true);
try {
const data = await getFleetMembers(fleet.id);
const data = await api.members(fleet.id);
setMembers(data);
} catch (error) {
const message = getErrorMessage(error, "");
@@ -1270,7 +1248,7 @@ function FleetDetail({
} finally {
setLoadingMembers(false);
}
}, [fleet.id, t]);
}, [api, fleet.id, t]);
useEffect(() => {
loadMembers();
@@ -1354,15 +1332,24 @@ function FleetDetail({
)}
</div>
{actionView === "run" && <RunCommandTab fleetId={fleet.id} />}
{actionView === "transfer" && <TransferTab fleetId={fleet.id} />}
{actionView === "inventory" && <InventoryTab fleetId={fleet.id} />}
{actionView === "packages" && <PackagesTab fleetId={fleet.id} />}
{actionView === "run" && (
<RunCommandTab api={api} fleetId={fleet.id} />
)}
{actionView === "transfer" && (
<TransferTab api={api} fleetId={fleet.id} />
)}
{actionView === "inventory" && (
<InventoryTab api={api} fleetId={fleet.id} />
)}
{actionView === "packages" && (
<PackagesTab api={api} fleetId={fleet.id} />
)}
</div>
)}
</div>
<MemberPickerDialog
api={api}
open={pickerOpen}
onClose={() => setPickerOpen(false)}
fleetId={fleet.id}
@@ -1372,6 +1359,7 @@ function FleetDetail({
/>
<FleetShareDialog
api={api}
open={shareOpen}
onClose={() => setShareOpen(false)}
fleet={fleet}
@@ -1381,15 +1369,17 @@ function FleetDetail({
}
export function FleetsPanel({
api,
active,
onOpenFleetInventory,
}: {
api: FleetsApi;
active?: boolean;
onOpenFleetInventory?: (fleetId: number) => void;
}) {
const { t } = useTranslation();
const { hosts: allHosts } = useHosts();
const [fleets, setFleets] = useState<FleetRow[]>([]);
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [selectedFleet, setSelectedFleet] = useState<FleetRow | null>(null);
@@ -1401,7 +1391,7 @@ export function FleetsPanel({
const loadFleets = useCallback(async () => {
try {
const data = await listFleets();
const data = await api.list();
setFleets(data);
setSelectedFleet((prev) =>
prev ? (data.find((f) => f.id === prev.id) ?? null) : null,
@@ -1410,28 +1400,24 @@ export function FleetsPanel({
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.fleets.loadFailed"));
}
}, [t]);
}, [api, t]);
useEffect(() => {
if (!active || hasLoadedRef.current) return;
hasLoadedRef.current = true;
setLoading(true);
Promise.all([listFleets(), getSSHHosts({ includeStatus: false })])
.then(([fleetData, hosts]) => {
setFleets(fleetData);
setAllHosts(hosts as unknown as SSHHost[]);
})
loadFleets()
.catch((error: unknown) => {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.fleets.loadFailed"));
})
.finally(() => setLoading(false));
}, [active, hasLoadedRef, t]);
}, [active, hasLoadedRef, loadFleets, t]);
async function handleDelete() {
if (!deleteTarget) return;
try {
await deleteFleet(deleteTarget.id);
await api.remove(deleteTarget.id);
toast.success(t("newUi.sidebar.fleets.fleetDeleted"));
if (selectedFleet?.id === deleteTarget.id) setSelectedFleet(null);
setDeleteTarget(null);
@@ -1449,6 +1435,7 @@ export function FleetsPanel({
if (selectedFleet) {
return (
<FleetDetail
api={api}
fleet={selectedFleet}
allHosts={allHosts}
onBack={() => setSelectedFleet(null)}
@@ -1578,6 +1565,7 @@ export function FleetsPanel({
</div>
<FleetFormDialog
api={api}
open={formOpen}
onClose={() => setFormOpen(false)}
fleet={editingFleet}
@@ -1585,6 +1573,7 @@ export function FleetsPanel({
/>
<FleetShareDialog
api={api}
open={!!shareTarget}
onClose={() => setShareTarget(null)}
fleet={shareTarget}
+149 -234
View File
@@ -1,9 +1,22 @@
import {
authApi,
handleApiError,
type SharePermissionLevel,
type ShareTarget,
} from "@/main-axios";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
export interface ShareTarget {
type: "user" | "role";
id: string | number;
}
export interface ShareableUser {
id: string;
username: string;
}
export interface ShareableRole {
id: number;
name: string;
displayName: string | null;
}
export interface FleetRow {
id: number;
@@ -25,7 +38,7 @@ export interface FleetMemberRow {
ip: string;
tags: string[];
static: boolean;
permissionLevel: "connect" | "view" | "edit" | "manage" | null;
permissionLevel: SharePermissionLevel | null;
}
export interface FleetHostResult {
@@ -58,243 +71,145 @@ export interface FleetInventoryEntry {
export type FleetPackageAction = "install" | "remove" | "upgrade-all";
export async function listFleets(): Promise<FleetRow[]> {
try {
const response = await authApi.get("/plugin-api/fleets");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleets");
}
}
export async function createFleet(fleetData: {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}): Promise<FleetRow> {
try {
const response = await authApi.post("/plugin-api/fleets", fleetData);
return response.data;
} catch (error) {
throw handleApiError(error, "create fleet");
}
}
export async function updateFleet(
fleetId: number,
fleetData: {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
},
): Promise<FleetRow> {
try {
const response = await authApi.patch(
`/plugin-api/fleets/${fleetId}`,
fleetData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "update fleet");
}
}
export async function deleteFleet(
fleetId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/plugin-api/fleets/${fleetId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete fleet");
}
}
export async function getFleetMembers(
fleetId: number,
): Promise<FleetMemberRow[]> {
try {
const response = await authApi.get(`/plugin-api/fleets/${fleetId}/members`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet members");
}
}
export async function addFleetMember(
fleetId: number,
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/members`,
{
hostId,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "add fleet member");
}
}
export async function removeFleetMember(
fleetId: number,
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/plugin-api/fleets/${fleetId}/members/${hostId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove fleet member");
}
}
export async function runFleetCommand(
fleetId: number,
command: string,
inputValues?: Record<string, string>,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/execute`,
{
command,
...(inputValues ? { inputValues } : {}),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet command");
}
}
export async function pushFleetFile(
fleetId: number,
file: File,
remotePath: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const form = new FormData();
form.append("file", file);
form.append("remotePath", remotePath);
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/transfer/push`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return response.data;
} catch (error) {
throw handleApiError(error, "push file to fleet");
}
}
export async function pullFleetFile(
fleetId: number,
remotePath: string,
): Promise<{ results: FleetHostResult[]; blob: Blob; fileName: string }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/transfer/pull`,
{ remotePath },
{ responseType: "blob" },
);
const resultsHeader = response.headers["x-fleet-transfer-results"];
const results: FleetHostResult[] = resultsHeader
? JSON.parse(atob(resultsHeader))
: [];
const disposition = response.headers["content-disposition"] as
string | undefined;
const match = disposition?.match(/filename="([^"]+)"/);
const fileName = match?.[1] ?? "fleet-transfer.zip";
return { results, blob: response.data, fileName };
} catch (error) {
throw handleApiError(error, "pull file from fleet");
}
}
export async function getFleetInventory(
fleetId: number,
): Promise<FleetInventoryEntry[]> {
try {
const response = await authApi.get(
`/plugin-api/fleets/${fleetId}/inventory`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet inventory");
}
}
export async function refreshFleetInventory(
fleetId: number,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/inventory`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "refresh fleet inventory");
}
}
export async function runFleetPackageAction(
fleetId: number,
action: FleetPackageAction,
packageName?: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/packages`,
{
action,
...(packageName ? { package: packageName } : {}),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet package action");
}
}
export interface FleetShareHostResult {
hostId: number;
shared: boolean;
reason?: string;
}
export async function shareFleet(
fleetId: number,
shareData: {
targets: ShareTarget[];
permissionLevel: SharePermissionLevel;
durationHours?: number;
},
): Promise<{
export interface FleetShareResult {
success: boolean;
permissionLevel: SharePermissionLevel;
expiresAt: string | null;
hostsShared: number;
hostsTotal: number;
hostResults: FleetShareHostResult[];
}> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/share`,
shareData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "share fleet");
}
}
/**
* The fleets routes, through the plugin's own client. Paths are relative to
* /plugin-api/fleets/, which the client already points at.
*/
export function createFleetsApi(api: PluginApiClient) {
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
return {
list: () => data(api.get<FleetRow[]>("/")),
create: (fleetData: {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}) => data(api.post<FleetRow>("/", fleetData)),
update: (
fleetId: number,
fleetData: {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
},
) => data(api.patch<FleetRow>(`/${fleetId}`, fleetData)),
remove: (fleetId: number) =>
data(api.delete<{ success: boolean }>(`/${fleetId}`)),
members: (fleetId: number) =>
data(api.get<FleetMemberRow[]>(`/${fleetId}/members`)),
addMember: (fleetId: number, hostId: number) =>
data(api.post<{ success: boolean }>(`/${fleetId}/members`, { hostId })),
removeMember: (fleetId: number, hostId: number) =>
data(api.delete<{ success: boolean }>(`/${fleetId}/members/${hostId}`)),
runCommand: (
fleetId: number,
command: string,
inputValues?: Record<string, string>,
) =>
data(
api.post<{ results: FleetHostResult[] }>(`/${fleetId}/execute`, {
command,
...(inputValues ? { inputValues } : {}),
}),
),
pushFile: async (fleetId: number, file: File, remotePath: string) => {
const form = new FormData();
form.append("file", file);
form.append("remotePath", remotePath);
return data(
api.post<{ results: FleetHostResult[] }>(
`/${fleetId}/transfer/push`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
),
);
},
pullFile: async (
fleetId: number,
remotePath: string,
): Promise<{
results: FleetHostResult[];
blob: Blob;
fileName: string;
}> => {
const response = await api.post<Blob>(
`/${fleetId}/transfer/pull`,
{ remotePath },
{ responseType: "blob" },
);
const headers = (response as { headers?: Record<string, string> })
.headers;
const resultsHeader = headers?.["x-fleet-transfer-results"];
const results: FleetHostResult[] = resultsHeader
? JSON.parse(atob(resultsHeader))
: [];
const disposition = headers?.["content-disposition"];
const match = disposition?.match(/filename="([^"]+)"/);
const fileName = match?.[1] ?? "fleet-transfer.zip";
return { results, blob: response.data, fileName };
},
inventory: (fleetId: number) =>
data(api.get<FleetInventoryEntry[]>(`/${fleetId}/inventory`)),
refreshInventory: (fleetId: number) =>
data(api.post<{ results: FleetHostResult[] }>(`/${fleetId}/inventory`)),
runPackageAction: (
fleetId: number,
action: FleetPackageAction,
packageName?: string,
) =>
data(
api.post<{ results: FleetHostResult[] }>(`/${fleetId}/packages`, {
action,
...(packageName ? { package: packageName } : {}),
}),
),
share: (
fleetId: number,
shareData: {
targets: ShareTarget[];
permissionLevel: SharePermissionLevel;
durationHours?: number;
},
) => data(api.post<FleetShareResult>(`/${fleetId}/share`, shareData)),
shareTargetUsers: () =>
data(api.get<{ users: ShareableUser[] }>("/share-targets/users")),
shareTargetRoles: () =>
data(api.get<{ roles: ShareableRole[] }>("/share-targets/roles")),
};
}
export type FleetsApi = ReturnType<typeof createFleetsApi>;
+44
View File
@@ -0,0 +1,44 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Extract a stable, human-readable message from an unknown thrown value.
* Mirrors src/ui/lib/error-message.ts - kept as a small standalone copy
* rather than a cross-plugin dependency for one pure function.
*/
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
export interface SnippetInput {
key: string;
label: string;
}
const INPUT_PATTERN =
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
/**
* Finds the $INPUT_n placeholders in a command, for the fleet run box's
* input fields. Mirrors src/ui/lib/snippet-variables.ts extractSnippetInputs.
*/
export function extractSnippetInputs(content: string): SnippetInput[] {
const seen = new Map<string, SnippetInput>();
let match: RegExpExecArray | null;
INPUT_PATTERN.lastIndex = 0;
while ((match = INPUT_PATTERN.exec(content)) !== null) {
const digits = match[1] ?? match[3];
const key = `INPUT_${digits}`;
if (!seen.has(key)) {
seen.set(key, { key, label: match[2]?.trim() || `Input ${digits}` });
}
}
return Array.from(seen.values());
}
+29 -25
View File
@@ -4,34 +4,38 @@ import type {
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { FleetsPanel } from "./FleetsPanel";
import { FleetInventoryTab } from "./FleetInventoryTab";
import { listFleets } from "./fleets-api";
import { FleetsPanel } from "./FleetsPanel.js";
import { FleetInventoryTab } from "./FleetInventoryTab.js";
import { createFleetsApi } from "./fleets-api.js";
const INVENTORY_TAB = "fleet-inventory";
function Panel({ active, shell }: PanelProps) {
return (
<FleetsPanel
active={active}
onOpenFleetInventory={(fleetId) =>
shell.openSingletonTab(INVENTORY_TAB, { data: { fleetId } })
}
/>
);
}
function InventoryTab({ tab, isVisible }: TabProps) {
const fleetId = tab.data?.fleetId;
return (
<FleetInventoryTab
fleetId={typeof fleetId === "number" ? fleetId : undefined}
isVisible={isVisible}
/>
);
}
export function activate(app: TermixApp): void {
const api = createFleetsApi(app.api);
function Panel({ active, shell }: PanelProps) {
return (
<FleetsPanel
api={api}
active={active}
onOpenFleetInventory={(fleetId) =>
shell.openSingletonTab(INVENTORY_TAB, { data: { fleetId } })
}
/>
);
}
function InventoryTab({ tab, isVisible }: TabProps) {
const fleetId = tab.data?.fleetId;
return (
<FleetInventoryTab
api={api}
fleetId={typeof fleetId === "number" ? fleetId : undefined}
isVisible={isVisible}
/>
);
}
app.registerRailItem({
id: "fleets",
icon: Boxes,
@@ -51,5 +55,5 @@ export function activate(app: TermixApp): void {
});
// Other plugins (automations' host picker) list fleets through this.
app.registerAction("fleets.list", () => listFleets());
app.registerAction("fleets.list", () => api.list());
}
+159
View File
@@ -0,0 +1,159 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* on an express app the way core mounts it: JSON parsed, the acting user set
* from the request, everything else left to the plugin.
*/
export async function startServer(
options: {
permissions?: string[];
users?: string[];
hosts?: Array<{
id: number;
userId: string;
name?: string;
ip?: string;
tags?: string | null;
}>;
} = {},
): Promise<TestServer> {
const hosts = options.hosts ?? [
{ id: 10, userId: "user-1", name: "host-a", ip: "10.0.0.1" },
{ id: 11, userId: "user-1", name: "host-b", ip: "10.0.0.2" },
];
const db = await createTestDb(pluginDir, {
before: (sqlite) => {
for (const user of options.users ?? ["user-1", "user-2"]) {
sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
// The plugin's fleet_members/fleet_inventory tables FK to ssh_data,
// which ctx.hosts (faked below) never touches - seed it directly so
// the constraint is satisfied the way it is against the real table.
for (const host of hosts) {
sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(host.id);
}
},
});
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? [
"fleets.view",
"fleets.manage",
"fleets.execute",
],
});
// ctx.hosts is capability-checked separately from ctx.db; seed it with the
// same fixture hosts a real deployment would resolve through core.
mock.ctx.hosts.list = async () =>
hosts.map((h) => ({
id: h.id,
userId: h.userId,
name: h.name ?? null,
ip: h.ip ?? "",
port: 22,
username: "root",
tags: h.tags ?? null,
folder: null,
authType: "password",
}));
mock.ctx.hosts.get = async (hostId) => {
const found = hosts.find((h) => h.id === hostId);
if (!found) return null;
return {
id: found.id,
userId: found.userId,
name: found.name ?? null,
ip: found.ip ?? "",
port: 22,
username: "root",
tags: found.tags ?? null,
folder: null,
authType: "password",
};
};
mock.ctx.hosts.checkAccess = async (hostId) => {
const found = hosts.find((h) => h.id === hostId);
if (!found) return { hasAccess: false, isOwner: false, isShared: false };
return {
hasAccess: true,
isOwner: true,
isShared: false,
permissionLevel: "manage",
};
};
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,191 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// fleets/fleet_members/fleet_inventory as core's SQLite bootstrap created
// them before 2.9.0, plus the indexes performance-indexes.ts added.
const LEGACY_DDL = `
CREATE TABLE fleets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
color TEXT,
icon TEXT,
tag_rules TEXT,
sync_id TEXT UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE fleet_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fleet_id INTEGER NOT NULL REFERENCES fleets(id) ON DELETE CASCADE,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_fleet_members_fleet_host ON fleet_members(fleet_id, host_id);
CREATE INDEX idx_fleet_members_host ON fleet_members(host_id);
CREATE TABLE fleet_inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
os_pretty_name TEXT,
kernel TEXT,
architecture TEXT,
hostname TEXT,
uptime_seconds INTEGER,
ip TEXT,
package_manager TEXT,
collected_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_fleet_inventory_host ON fleet_inventory(host_id, user_id);
CREATE INDEX idx_fleet_inventory_user ON fleet_inventory(user_id);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexOn = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as { name: string }[]
)
.map((row) => row.name)
.sort();
describe("adopting fleets, fleet_members, fleet_inventory", () => {
it("keeps every existing row when the legacy tables are there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO fleets (user_id, name, tag_rules, sync_id) VALUES (?, ?, ?, ?)",
)
.run("user-1", "Web fleet", '["prod-web"]', "sync-a");
sqlite
.prepare(
"INSERT INTO fleet_members (fleet_id, host_id) VALUES (1, 1)",
)
.run();
sqlite
.prepare(
"INSERT INTO fleet_inventory (host_id, user_id, os_pretty_name) VALUES (1, 'user-1', 'Debian')",
)
.run();
},
});
expect(db.applied).toEqual(["0001_adopt_fleets"]);
expect(tableExists("fleets")).toBe(false);
expect(tableExists("fleet_members")).toBe(false);
expect(tableExists("fleet_inventory")).toBe(false);
expect(tableExists("p_fleets_fleets")).toBe(true);
expect(tableExists("p_fleets_members")).toBe(true);
expect(tableExists("p_fleets_inventory")).toBe(true);
const fleetRows = db.sqlite
.prepare("SELECT user_id, name, tag_rules, sync_id FROM p_fleets_fleets")
.all();
expect(fleetRows).toEqual([
{
user_id: "user-1",
name: "Web fleet",
tag_rules: '["prod-web"]',
sync_id: "sync-a",
},
]);
const memberRows = db.sqlite
.prepare("SELECT fleet_id, host_id FROM p_fleets_members")
.all();
expect(memberRows).toEqual([{ fleet_id: 1, host_id: 1 }]);
const inventoryRows = db.sqlite
.prepare(
"SELECT host_id, user_id, os_pretty_name FROM p_fleets_inventory",
)
.all();
expect(inventoryRows).toEqual([
{ host_id: 1, user_id: "user-1", os_pretty_name: "Debian" },
]);
expect(indexOn("p_fleets_members")).toEqual(
["idx_fleet_members_fleet_host", "idx_fleet_members_host"].sort(),
);
expect(indexOn("p_fleets_inventory")).toEqual(
["idx_fleet_inventory_host", "idx_fleet_inventory_user"].sort(),
);
});
it("creates the tables fresh when there are no legacy ones", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("fleets")).toBe(false);
expect(tableExists("p_fleets_fleets")).toBe(true);
expect(tableExists("p_fleets_members")).toBe(true);
expect(tableExists("p_fleets_inventory")).toBe(true);
expect(db.sqlite.prepare("SELECT * FROM p_fleets_fleets").all()).toEqual(
[],
);
});
it("cascades a fleet delete onto its member rows via the fleet_id FK", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
},
});
db.sqlite
.prepare(
"INSERT INTO p_fleets_fleets (id, user_id, name) VALUES (1, 'user-1', 'Fleet')",
)
.run();
db.sqlite
.prepare("INSERT INTO p_fleets_members (fleet_id, host_id) VALUES (1, 1)")
.run();
db.sqlite.exec("DELETE FROM p_fleets_fleets WHERE id = 1");
expect(db.sqlite.prepare("SELECT * FROM p_fleets_members").all()).toEqual(
[],
);
});
it("removes a user's fleets when the user is deleted", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
},
});
const insert = db.sqlite.prepare(
"INSERT INTO p_fleets_fleets (user_id, name) VALUES (?, ?)",
);
insert.run("user-1", "Mine");
insert.run("user-2", "Theirs");
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
const names = (
db.sqlite.prepare("SELECT name FROM p_fleets_fleets").all() as {
name: string;
}[]
).map((row) => row.name);
expect(names).toEqual(["Theirs"]);
});
});
@@ -1,103 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
import { FleetRepository } from "../../../../../src/backend/database/repositories/fleet-repository.js";
describe("FleetRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<{ repository: FleetRepository }> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type, tags)
VALUES
(1, 'user-1', 'web-1', '10.0.0.1', 22, 'root', 'password', 'prod-web,edge'),
(2, 'user-1', 'web-2', '10.0.0.2', 22, 'root', 'password', 'prod-web'),
(3, 'user-1', 'db-1', '10.0.0.3', 22, 'root', 'password', 'prod-db'),
(4, 'user-1', 'static-only', '10.0.0.4', 22, 'root', 'password', NULL),
(5, 'user-2', 'other-user-host', '10.0.0.5', 22, 'root', 'password', 'prod-web');
`);
return { repository: new FleetRepository(context, onWrite) };
}
it("unions static membership and tag-matched hosts, deduplicated by id", async () => {
let writes = 0;
const { repository } = await createRepository(() => {
writes += 1;
});
const fleet = await repository.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
// host 1 matches by tag AND is added statically - must appear once.
await repository.addMember(fleet.id, 1);
// host 4 has no tags - only reachable via static membership.
await repository.addMember(fleet.id, 4);
const members = await repository.listEffectiveMembers("user-1", fleet.id);
const ids = members.map((m) => m.id).sort((a, b) => a - b);
expect(ids).toEqual([1, 2, 4]);
expect(writes).toBeGreaterThan(0);
});
it("never returns another user's hosts even if tags match", async () => {
const { repository } = await createRepository();
const fleet = await repository.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
const members = await repository.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).not.toContain(5);
});
it("returns only static members when a fleet has no tag rules", async () => {
const { repository } = await createRepository();
const fleet = await repository.create("user-1", { name: "static fleet" });
await repository.addMember(fleet.id, 3);
const members = await repository.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).toEqual([3]);
});
it("removeMember only drops the static row, not tag-based membership", async () => {
const { repository } = await createRepository();
const fleet = await repository.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
await repository.addMember(fleet.id, 1);
await expect(repository.removeMember(fleet.id, 1)).resolves.toBe(true);
// host 1 still matches the tag rule, so it remains an effective member.
const members = await repository.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).toContain(1);
});
it("returns an empty list for a fleet the caller does not own", async () => {
const { repository } = await createRepository();
const fleet = await repository.create("user-1", { name: "private" });
await expect(
repository.listEffectiveMembers("user-2", fleet.id),
).resolves.toEqual([]);
});
});
@@ -0,0 +1,209 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import type {
PluginHostSummary,
PluginHosts,
} from "@termix/plugin-sdk/backend";
import { pluginDir } from "./helpers";
import {
fleets,
fleetMembers,
fleetInventory,
} from "../../src/backend/tables.js";
import { createFleetRepository } from "../../src/backend/repository.js";
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
function fakeHosts(hosts: PluginHostSummary[]): PluginHosts {
return {
list: async () => hosts,
get: async (hostId) => hosts.find((h) => h.id === hostId) ?? null,
checkAccess: async (hostId) => {
const found = hosts.find((h) => h.id === hostId);
return found
? {
hasAccess: true,
isOwner: true,
isShared: false,
permissionLevel: "manage",
}
: { hasAccess: false, isOwner: false, isShared: false };
},
share: async (hostId) => ({ hostId, shared: true }),
listUsers: async () => [],
listRoles: async () => [],
};
}
async function setup(hosts: PluginHostSummary[]) {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec(
`INSERT INTO ssh_data (id) VALUES ${hosts.map((h) => `(${h.id})`).join(", ")}`,
);
},
});
const fleetsTable = await db.database.define(fleets);
const membersTable = await db.database.define(fleetMembers);
const inventoryTable = await db.database.define(fleetInventory);
const repo = createFleetRepository(
db.database,
fakeHosts(hosts),
fleetsTable,
membersTable,
inventoryTable,
);
return repo;
}
function host(
id: number,
overrides: Partial<PluginHostSummary> = {},
): PluginHostSummary {
return {
id,
userId: "user-1",
name: `host-${id}`,
ip: `10.0.0.${id}`,
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
...overrides,
};
}
describe("FleetRepository.listEffectiveMembers", () => {
it("unions static membership and tag-matched hosts, deduplicated by id", async () => {
const repo = await setup([
host(1, { tags: "prod-web,edge" }),
host(2, { tags: "prod-web" }),
host(3, { tags: "prod-db" }),
host(4, { tags: null }),
host(5, { userId: "user-2", tags: "prod-web" }),
]);
const fleet = await repo.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
// host 1 matches by tag AND is added statically - must appear once.
await repo.addMember(fleet.id, 1);
// host 4 has no tags - only reachable via static membership.
await repo.addMember(fleet.id, 4);
const members = await repo.listEffectiveMembers("user-1", fleet.id);
const ids = members.map((m) => m.id).sort((a, b) => a - b);
expect(ids).toEqual([1, 2, 4]);
});
it("never returns another user's hosts even if tags match", async () => {
const repo = await setup([
host(1, { tags: "prod-web" }),
host(5, { userId: "user-2", tags: "prod-web" }),
]);
const fleet = await repo.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
const members = await repo.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).not.toContain(5);
});
it("returns only static members when a fleet has no tag rules", async () => {
const repo = await setup([host(3, { tags: "prod-db" })]);
const fleet = await repo.create("user-1", { name: "static fleet" });
await repo.addMember(fleet.id, 3);
const members = await repo.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).toEqual([3]);
});
it("removeMember only drops the static row, not tag-based membership", async () => {
const repo = await setup([host(1, { tags: "prod-web" })]);
const fleet = await repo.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
await repo.addMember(fleet.id, 1);
await expect(repo.removeMember(fleet.id, 1)).resolves.toBe(true);
// host 1 still matches the tag rule, so it remains an effective member.
const members = await repo.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).toContain(1);
});
it("returns an empty list for a fleet the caller does not own", async () => {
const repo = await setup([host(1)]);
const fleet = await repo.create("user-1", { name: "private" });
await expect(
repo.listEffectiveMembers("user-2", fleet.id),
).resolves.toEqual([]);
});
});
describe("FleetRepository CRUD", () => {
it("creates, updates and deletes a fleet", async () => {
const repo = await setup([host(1)]);
const created = await repo.create("user-1", {
name: "Fleet",
tagRules: ["a"],
});
expect(created.name).toBe("Fleet");
expect(created.tagRules).toBe('["a"]');
const updated = await repo.update("user-1", created.id, {
name: "Renamed",
});
expect(updated?.name).toBe("Renamed");
await expect(repo.delete("user-1", created.id)).resolves.toBe(true);
await expect(repo.findById("user-1", created.id)).resolves.toBeNull();
});
it("upserts and lists inventory per host", async () => {
const repo = await setup([host(1)]);
await repo.upsertInventory("user-1", 1, {
osPrettyName: "Debian",
kernel: "6.1.0",
architecture: "x86_64",
hostname: "web-1",
uptimeSeconds: 100,
ip: "10.0.0.1",
packageManager: "apt",
});
const rows = await repo.listInventoryForHosts("user-1", [1]);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ hostId: 1, osPrettyName: "Debian" });
// Refreshing overwrites the latest-only snapshot rather than adding a row.
await repo.upsertInventory("user-1", 1, {
osPrettyName: "Ubuntu",
kernel: "6.2.0",
architecture: "x86_64",
hostname: "web-1",
uptimeSeconds: 200,
ip: "10.0.0.1",
packageManager: "apt",
});
const refreshed = await repo.listInventoryForHosts("user-1", [1]);
expect(refreshed).toHaveLength(1);
expect(refreshed[0].osPrettyName).toBe("Ubuntu");
});
});
+106 -371
View File
@@ -1,388 +1,125 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, Response, Router } from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
import { parseInventoryProbe } from "../../src/backend/routes.js";
const state = vi.hoisted(() => ({
currentUserId: "user-1",
fleets: new Map<number, { id: number; userId: string; name: string }>(),
members: new Map<number, { id: number; name: string }[]>(),
hostAccess: new Map<string, { hasAccess: boolean; isOwner: boolean }>(),
hosts: new Map<number, Record<string, unknown>>(),
}));
let server: TestServer;
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
vi.mock("../../../../src/backend/utils/logger.js", () => ({
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
databaseLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../../src/backend/utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async (userId: string, hostId: number, level: string) => {
const key = `${userId}:${hostId}:${level}`;
const found = state.hostAccess.get(key);
if (found) return found;
// default: full access unless a test explicitly denies it
return { hasAccess: true, isOwner: true, permissionLevel: "manage" };
},
}),
},
}));
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
resolveHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
}));
vi.mock("../../src/backend/ssh.js", () => ({
withSshConnection: async (
_host: unknown,
_options: unknown,
fn: (client: unknown) => unknown,
) => fn({}),
}));
vi.mock("../../../../src/backend/hosts/metrics-shared/common-utils.js", () => ({
execCommand: vi.fn(async () => ({ stdout: "ok", stderr: "", code: 0 })),
}));
vi.mock("../../../../src/backend/hosts/metrics-shared/platform.js", () => ({
detectPlatform: vi.fn(async () => ({ pkg: "apt", osPrettyName: "Debian" })),
}));
vi.mock(
"../../../../src/backend/hosts/metrics-shared/exec-elevated.js",
async () => {
class ElevationError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
return {
execElevated: vi.fn(),
ElevationError,
};
},
);
vi.mock(
"../../../../src/backend/hosts/metrics-shared/package-commands.js",
() => ({
buildPackageActionCommand: vi.fn(() => "apt-get install -y foo"),
}),
);
vi.mock("../../../../src/backend/hosts/metrics-shared/validation.js", () => ({
isValidPackageName: (v: unknown) => typeof v === "string" && v.length > 0,
}));
vi.mock(
"../../../../src/backend/database/routes/snippets-execution.js",
() => ({
resolveSnippetCommand: (command: string) => command,
}),
);
vi.mock("../../../../src/backend/database/routes/rbac.js", () => ({
isSharePermissionLevel: (v: unknown) =>
["connect", "view", "edit", "manage"].includes(v as string),
expiryFromDuration: () => null,
parseShareTargets: () => null,
}));
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentFleetRepository: () => ({
findById: async (userId: string, fleetId: number) => {
const fleet = state.fleets.get(fleetId);
return fleet && fleet.userId === userId ? fleet : null;
},
listEffectiveMembers: async (_userId: string, fleetId: number) =>
state.members.get(fleetId) ?? [],
listStaticMemberIds: async () => [],
listByUser: async (userId: string) =>
[...state.fleets.values()].filter((f) => f.userId === userId),
}),
createCurrentFleetInventoryRepository: () => ({
listForHosts: async () => [],
upsert: async () => ({}),
}),
createCurrentRbacAccessRepository: () => ({}),
createCurrentRoleRepository: () => ({}),
createCurrentUserRepository: () => ({}),
}));
vi.mock("../../../../src/backend/database/routes/fleet-dispatch.js", () => ({
registerFleetsRouter: vi.fn(),
unregisterFleetsRouter: vi.fn(),
}));
const { router, parseInventoryProbe, buildRemoveCommand } =
await import("../../src/backend/routes.js");
function findLayer(method: string, path: string) {
const stack = (router as unknown as Router).stack as Array<{
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
};
}>;
const layer = stack.find(
(l) => l.route?.path === path && l.route?.methods[method],
);
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function makeReqRes(overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
}) {
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
params: overrides.params ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
} as unknown as Response & { statusCode: number; jsonBody: unknown };
return { req, res };
}
async function invoke(
method: string,
path: string,
overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
} = {},
) {
const handler = findLayer(method, path);
const { req, res } = makeReqRes(overrides);
await handler(req, res);
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
beforeEach(() => {
state.currentUserId = "user-1";
state.fleets = new Map([[1, { id: 1, userId: "user-1", name: "web fleet" }]]);
state.members = new Map([
[
1,
[
{ id: 10, name: "host-a" },
{ id: 11, name: "host-b" },
],
],
]);
state.hostAccess = new Map();
state.hosts = new Map([
[
10,
{
id: 10,
userId: "user-1",
name: "host-a",
ip: "10.0.0.1",
port: 22,
username: "root",
sudoPassword: "secret",
},
],
[
11,
{
id: 11,
userId: "user-1",
name: "host-b",
ip: "10.0.0.2",
port: 22,
username: "root",
sudoPassword: "secret",
},
],
]);
afterEach(async () => {
await server?.close();
});
describe("GET /:id/members", () => {
it("404s for a fleet the caller does not own", async () => {
const res = await invoke("get", "/:id/members", {
params: { id: "999" },
async function createFleet(
server: TestServer,
body: Record<string, unknown> = { name: "Web fleet" },
) {
const res = await server.request("POST", "/", { body });
expect(res.status).toBe(200);
return res.body;
}
describe("fleet CRUD routes", () => {
beforeEach(async () => {
server = await startServer();
});
it("creates and lists fleets with member counts", async () => {
const created = await createFleet(server);
expect(created).toMatchObject({ name: "Web fleet", tagRules: [] });
const list = await server.request("GET", "/");
expect(list.status).toBe(200);
expect(list.body).toHaveLength(1);
expect(list.body[0]).toMatchObject({ id: created.id, memberCount: 0 });
});
it("400s on a missing name", async () => {
const res = await server.request("POST", "/", { body: {} });
expect(res.status).toBe(400);
});
it("updates and deletes a fleet", async () => {
const created = await createFleet(server);
const updated = await server.request("PATCH", `/${created.id}`, {
body: { name: "Renamed" },
});
expect(res.statusCode).toBe(404);
expect(updated.status).toBe(200);
expect(updated.body.name).toBe("Renamed");
const deleted = await server.request("DELETE", `/${created.id}`);
expect(deleted.status).toBe(200);
expect((await server.request("GET", "/")).body).toHaveLength(0);
});
it("404s for a fleet the caller does not own", async () => {
const res = await server.request("GET", "/999/members");
expect(res.status).toBe(404);
});
it("400s on a non-numeric fleet id", async () => {
const res = await invoke("get", "/:id/members", {
params: { id: "not-a-number" },
});
expect(res.statusCode).toBe(400);
const res = await server.request("GET", "/not-a-number/members");
expect(res.status).toBe(400);
});
});
describe("POST /:id/execute", () => {
it("400s when command is missing", async () => {
const res = await invoke("post", "/:id/execute", {
params: { id: "1" },
body: {},
});
expect(res.statusCode).toBe(400);
describe("fleet membership routes", () => {
beforeEach(async () => {
server = await startServer();
});
it("reports per-host success with the {hostId, hostName, success} shape", async () => {
const res = await invoke("post", "/:id/execute", {
params: { id: "1" },
body: { command: "uptime" },
});
expect(res.statusCode).toBe(200);
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({
hostId: 10,
hostName: "host-a",
success: true,
it("adds and lists members, then removes one", async () => {
const fleet = await createFleet(server);
const added = await server.request("POST", `/${fleet.id}/members`, {
body: { hostId: 10 },
});
expect(added.status).toBe(200);
const members = await server.request("GET", `/${fleet.id}/members`);
expect(members.status).toBe(200);
expect(members.body).toHaveLength(1);
expect(members.body[0]).toMatchObject({ id: 10, static: true });
const removed = await server.request("DELETE", `/${fleet.id}/members/10`);
expect(removed.status).toBe(200);
expect(
(await server.request("GET", `/${fleet.id}/members`)).body,
).toHaveLength(0);
});
it("isolates one host's access denial - the other host still succeeds", async () => {
state.hostAccess.set("user-1:10:edit", {
hasAccess: false,
isOwner: false,
it("404s when adding a host outside the fixture host list", async () => {
const fleet = await createFleet(server);
const res = await server.request("POST", `/${fleet.id}/members`, {
body: { hostId: 999 },
});
const res = await invoke("post", "/:id/execute", {
params: { id: "1" },
body: { command: "uptime" },
});
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
const denied = results.find((r) => r.hostId === 10);
const allowed = results.find((r) => r.hostId === 11);
expect(denied).toMatchObject({ success: false });
expect(String(denied?.error)).toMatch(/edit/);
expect(allowed).toMatchObject({ success: true });
});
it("404s for a fleet the caller does not own", async () => {
const res = await invoke("post", "/:id/execute", {
params: { id: "999" },
body: { command: "uptime" },
});
expect(res.statusCode).toBe(404);
expect(res.status).toBe(404);
});
});
describe("POST /:id/packages", () => {
it("400s on an invalid action", async () => {
const res = await invoke("post", "/:id/packages", {
params: { id: "1" },
body: { action: "reformat-disk" },
describe("permission gates", () => {
it("view routes 403 without fleets.view", async () => {
server = await startServer({
permissions: ["fleets.manage", "fleets.execute"],
});
expect(res.statusCode).toBe(400);
const res = await server.request("GET", "/");
expect(res.status).toBe(403);
});
it("surfaces an ElevationError as a per-host error, not a request failure", async () => {
const { execElevated, ElevationError } =
await import("../../../../src/backend/hosts/metrics-shared/exec-elevated.js");
(execElevated as ReturnType<typeof vi.fn>).mockRejectedValue(
new ElevationError("SUDO_REQUIRED", "sudo password required"),
);
const res = await invoke("post", "/:id/packages", {
params: { id: "1" },
body: { action: "install", package: "curl" },
it("manage routes 403 without fleets.manage", async () => {
server = await startServer({
permissions: ["fleets.view", "fleets.execute"],
});
expect(res.statusCode).toBe(200);
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
expect(results.every((r) => r.success === false)).toBe(true);
expect(results[0].error).toMatch(/sudo password required/);
const res = await server.request("POST", "/", { body: { name: "x" } });
expect(res.status).toBe(403);
});
it("requires manage-level access, not just edit", async () => {
state.hostAccess.set("user-1:10:manage", {
hasAccess: false,
isOwner: false,
it("execute routes 403 without fleets.execute", async () => {
server = await startServer({
permissions: ["fleets.view", "fleets.manage"],
});
state.hostAccess.set("user-1:11:manage", {
hasAccess: false,
isOwner: false,
const fleet = await createFleet(server);
const res = await server.request("POST", `/${fleet.id}/execute`, {
body: { command: "uptime" },
});
const { execElevated } =
await import("../../../../src/backend/hosts/metrics-shared/exec-elevated.js");
(execElevated as ReturnType<typeof vi.fn>).mockResolvedValue({
code: 0,
stdout: "done",
stderr: "",
});
const res = await invoke("post", "/:id/packages", {
params: { id: "1" },
body: { action: "upgrade-all" },
});
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
expect(results.every((r) => r.success === false)).toBe(true);
expect(String(results[0].error)).toMatch(/manage/);
});
});
describe("POST /:id/members", () => {
it("404s when the caller cannot access the target host", async () => {
state.hostAccess.set("user-1:99:connect", {
hasAccess: false,
isOwner: false,
});
const res = await invoke("post", "/:id/members", {
params: { id: "1" },
body: { hostId: 99 },
});
expect(res.statusCode).toBe(404);
expect(res.status).toBe(403);
});
});
@@ -413,8 +150,7 @@ describe("parseInventoryProbe", () => {
});
it("nulls uptimeSeconds when the value is not a bare integer", () => {
const out = "uptime_seconds=";
expect(parseInventoryProbe(out).uptimeSeconds).toBeNull();
expect(parseInventoryProbe("uptime_seconds=").uptimeSeconds).toBeNull();
});
it("ignores lines with no '=' separator", () => {
@@ -423,19 +159,18 @@ describe("parseInventoryProbe", () => {
});
});
describe("buildRemoveCommand", () => {
it("builds the correct remove command per package manager", () => {
expect(buildRemoveCommand("apt", "curl")).toContain(
"apt-get -y remove curl",
);
expect(buildRemoveCommand("dnf", "curl")).toBe("dnf -y remove curl");
expect(buildRemoveCommand("yum", "curl")).toBe("yum -y remove curl");
expect(buildRemoveCommand("pacman", "curl")).toBe(
"pacman -R --noconfirm curl",
);
describe("share target pickers", () => {
beforeEach(async () => {
server = await startServer();
});
it("returns null when no package manager was detected", () => {
expect(buildRemoveCommand(null, "curl")).toBeNull();
it("lists users and roles through ctx.hosts", async () => {
const users = await server.request("GET", "/share-targets/users");
expect(users.status).toBe(200);
expect(users.body).toEqual({ users: [] });
const roles = await server.request("GET", "/share-targets/roles");
expect(roles.status).toBe(200);
expect(roles.body).toEqual({ roles: [] });
});
});