mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
This commit is contained in:
1 parent
d2ff5fc78f
commit
3b2c849514
59 files changed
+31807
-2977
No files matched your search
@@ -2664,87 +2664,6 @@ const migrateSchema = () => {
|
||||
}
|
||||
// --- homepage end ---
|
||||
|
||||
// --- fleets begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM fleets LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create fleets table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM fleet_members LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
`);
|
||||
sqlite.exec(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fleet_members_fleet_host ON fleet_members(fleet_id, host_id)",
|
||||
);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create fleet_members table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM fleet_inventory LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
`);
|
||||
sqlite.exec(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fleet_inventory_host ON fleet_inventory(host_id, user_id)",
|
||||
);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create fleet_inventory table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- fleets end ---
|
||||
|
||||
// --- sync begin ---
|
||||
// Stable per-row identity used to match rows across two independently-
|
||||
// seeded databases (the embedded desktop backend and a connected remote
|
||||
|
||||
@@ -168,23 +168,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
columns: "host_id",
|
||||
},
|
||||
|
||||
// Fleets.
|
||||
{
|
||||
name: "idx_fleet_members_fleet",
|
||||
table: "fleet_members",
|
||||
columns: "fleet_id",
|
||||
},
|
||||
{
|
||||
name: "idx_fleet_members_host",
|
||||
table: "fleet_members",
|
||||
columns: "host_id",
|
||||
},
|
||||
{
|
||||
name: "idx_fleet_inventory_user",
|
||||
table: "fleet_inventory",
|
||||
columns: "user_id",
|
||||
},
|
||||
|
||||
// Automations.
|
||||
{
|
||||
name: "idx_automations_user",
|
||||
|
||||
@@ -1616,81 +1616,6 @@ export const homepageLayouts = mysqlTable("homepage_layouts", {
|
||||
});
|
||||
// --- homepage end ---
|
||||
|
||||
// --- fleets begin ---
|
||||
export const fleets = mysqlTable("fleets", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
// JSON array of { tag: string } rules, unioned with static fleetMembers at
|
||||
// resolution time. Kept to tag-equality matching for v1.
|
||||
tagRules: text("tag_rules"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
|
||||
export const fleetMembers = mysqlTable(
|
||||
"fleet_members",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
fleetId: int("fleet_id")
|
||||
.notNull()
|
||||
.references(() => fleets.id, { onDelete: "cascade" }),
|
||||
hostId: int("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
addedAt: text("added_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
// fleet_id leads the unique pair, so listing a fleet's hosts is already
|
||||
// served. Finding the fleets a host belongs to starts from host_id.
|
||||
(table) => [
|
||||
uniqueIndex("idx_fleet_members_fleet_host").on(table.fleetId, table.hostId),
|
||||
index("idx_fleet_members_host").on(table.hostId),
|
||||
],
|
||||
);
|
||||
|
||||
// Latest-only inventory snapshot per host, overwritten on each refresh - no
|
||||
// historical log, matching the "latest snapshot only" scope decision.
|
||||
export const fleetInventory = mysqlTable(
|
||||
"fleet_inventory",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
hostId: int("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
osPrettyName: text("os_pretty_name"),
|
||||
kernel: text("kernel"),
|
||||
architecture: text("architecture"),
|
||||
hostname: text("hostname"),
|
||||
uptimeSeconds: int("uptime_seconds"),
|
||||
ip: text("ip"),
|
||||
packageManager: text("package_manager"),
|
||||
collectedAt: text("collected_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
// host_id leads the unique pair; a user's whole inventory is read by user_id.
|
||||
(table) => [
|
||||
uniqueIndex("idx_fleet_inventory_host").on(table.hostId, table.userId),
|
||||
index("idx_fleet_inventory_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
// --- fleets end ---
|
||||
|
||||
|
||||
|
||||
// --- sync begin ---
|
||||
|
||||
@@ -1617,81 +1617,6 @@ export const homepageLayouts = pgTable("homepage_layouts", {
|
||||
});
|
||||
// --- homepage end ---
|
||||
|
||||
// --- fleets begin ---
|
||||
export const fleets = pgTable("fleets", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
// JSON array of { tag: string } rules, unioned with static fleetMembers at
|
||||
// resolution time. Kept to tag-equality matching for v1.
|
||||
tagRules: text("tag_rules"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const fleetMembers = pgTable(
|
||||
"fleet_members",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
fleetId: integer("fleet_id")
|
||||
.notNull()
|
||||
.references(() => fleets.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
addedAt: text("added_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
// fleet_id leads the unique pair, so listing a fleet's hosts is already
|
||||
// served. Finding the fleets a host belongs to starts from host_id.
|
||||
(table) => [
|
||||
uniqueIndex("idx_fleet_members_fleet_host").on(table.fleetId, table.hostId),
|
||||
index("idx_fleet_members_host").on(table.hostId),
|
||||
],
|
||||
);
|
||||
|
||||
// Latest-only inventory snapshot per host, overwritten on each refresh - no
|
||||
// historical log, matching the "latest snapshot only" scope decision.
|
||||
export const fleetInventory = pgTable(
|
||||
"fleet_inventory",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
osPrettyName: text("os_pretty_name"),
|
||||
kernel: text("kernel"),
|
||||
architecture: text("architecture"),
|
||||
hostname: text("hostname"),
|
||||
uptimeSeconds: integer("uptime_seconds"),
|
||||
ip: text("ip"),
|
||||
packageManager: text("package_manager"),
|
||||
collectedAt: text("collected_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
// host_id leads the unique pair; a user's whole inventory is read by user_id.
|
||||
(table) => [
|
||||
uniqueIndex("idx_fleet_inventory_host").on(table.hostId, table.userId),
|
||||
index("idx_fleet_inventory_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
// --- fleets end ---
|
||||
|
||||
|
||||
|
||||
// --- sync begin ---
|
||||
|
||||
@@ -1615,81 +1615,6 @@ export const homepageLayouts = sqliteTable("homepage_layouts", {
|
||||
});
|
||||
// --- homepage end ---
|
||||
|
||||
// --- fleets begin ---
|
||||
export const fleets = sqliteTable("fleets", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
// JSON array of { tag: string } rules, unioned with static fleetMembers at
|
||||
// resolution time. Kept to tag-equality matching for v1.
|
||||
tagRules: text("tag_rules"),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const fleetMembers = sqliteTable(
|
||||
"fleet_members",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
fleetId: integer("fleet_id")
|
||||
.notNull()
|
||||
.references(() => fleets.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
addedAt: text("added_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
// fleet_id leads the unique pair, so listing a fleet's hosts is already
|
||||
// served. Finding the fleets a host belongs to starts from host_id.
|
||||
(table) => [
|
||||
uniqueIndex("idx_fleet_members_fleet_host").on(table.fleetId, table.hostId),
|
||||
index("idx_fleet_members_host").on(table.hostId),
|
||||
],
|
||||
);
|
||||
|
||||
// Latest-only inventory snapshot per host, overwritten on each refresh - no
|
||||
// historical log, matching the "latest snapshot only" scope decision.
|
||||
export const fleetInventory = sqliteTable(
|
||||
"fleet_inventory",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
osPrettyName: text("os_pretty_name"),
|
||||
kernel: text("kernel"),
|
||||
architecture: text("architecture"),
|
||||
hostname: text("hostname"),
|
||||
uptimeSeconds: integer("uptime_seconds"),
|
||||
ip: text("ip"),
|
||||
packageManager: text("package_manager"),
|
||||
collectedAt: text("collected_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
// host_id leads the unique pair; a user's whole inventory is read by user_id.
|
||||
(table) => [
|
||||
uniqueIndex("idx_fleet_inventory_host").on(table.hostId, table.userId),
|
||||
index("idx_fleet_inventory_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
// --- fleets end ---
|
||||
|
||||
|
||||
|
||||
// --- sync begin ---
|
||||
|
||||
@@ -15,8 +15,6 @@ import { CredentialRepository } from "./credential-repository.js";
|
||||
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
|
||||
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
|
||||
import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js";
|
||||
import { FleetRepository } from "./fleet-repository.js";
|
||||
import { FleetInventoryRepository } from "./fleet-inventory-repository.js";
|
||||
import { HomepageItemRepository } from "./homepage-item-repository.js";
|
||||
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
|
||||
import { HostFolderRepository } from "./host-folder-repository.js";
|
||||
@@ -239,20 +237,6 @@ export function createCurrentFileManagerBookmarkRepository(): FileManagerBookmar
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentFleetRepository(): FleetRepository {
|
||||
return new FleetRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("fleet_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentFleetInventoryRepository(): FleetInventoryRepository {
|
||||
return new FleetInventoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("fleet_inventory_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHomepageItemRepository(): HomepageItemRepository {
|
||||
return new HomepageItemRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { fleetInventory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type FleetInventoryRecord = typeof fleetInventory.$inferSelect;
|
||||
|
||||
export interface FleetInventoryInput {
|
||||
osPrettyName: string | null;
|
||||
kernel: string | null;
|
||||
architecture: string | null;
|
||||
hostname: string | null;
|
||||
uptimeSeconds: number | null;
|
||||
ip: string | null;
|
||||
packageManager: string | null;
|
||||
}
|
||||
|
||||
export class FleetInventoryRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async upsert(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
input: FleetInventoryInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<FleetInventoryRecord> {
|
||||
const existing = await this.findOne(userId, hostId);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
fleetInventory,
|
||||
{ ...input, collectedAt: now },
|
||||
eq(fleetInventory.id, existing.id),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await insertReturning(this.context, fleetInventory, {
|
||||
userId,
|
||||
hostId,
|
||||
...input,
|
||||
collectedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findOne(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<FleetInventoryRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(fleetInventory)
|
||||
.where(
|
||||
and(
|
||||
eq(fleetInventory.userId, userId),
|
||||
eq(fleetInventory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listForHosts(
|
||||
userId: string,
|
||||
hostIds: number[],
|
||||
): Promise<FleetInventoryRecord[]> {
|
||||
if (hostIds.length === 0) return [];
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fleetInventory)
|
||||
.where(
|
||||
and(
|
||||
eq(fleetInventory.userId, userId),
|
||||
inArray(fleetInventory.hostId, hostIds),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { fleets, fleetMembers, hosts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.js";
|
||||
|
||||
export type FleetRecord = typeof fleets.$inferSelect;
|
||||
export type FleetMemberHostRecord = typeof hosts.$inferSelect;
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
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 class FleetRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUser(userId: string): Promise<FleetRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fleets)
|
||||
.where(eq(fleets.userId, userId));
|
||||
}
|
||||
|
||||
async findById(userId: string, fleetId: number): Promise<FleetRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(fleets)
|
||||
.where(and(eq(fleets.id, fleetId), eq(fleets.userId, userId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
input: FleetCreateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<FleetRecord> {
|
||||
const [created] = await insertReturning(this.context, fleets, {
|
||||
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: randomUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
fleetId: number,
|
||||
input: FleetUpdateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<FleetRecord | null> {
|
||||
const existing = await this.findById(userId, fleetId);
|
||||
if (!existing) return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
fleets,
|
||||
{
|
||||
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,
|
||||
},
|
||||
and(eq(fleets.id, fleetId), eq(fleets.userId, userId)),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async delete(userId: string, fleetId: number): Promise<boolean> {
|
||||
const deleted = await deleteReturning(
|
||||
this.context,
|
||||
fleets,
|
||||
and(eq(fleets.id, fleetId), eq(fleets.userId, userId)),
|
||||
);
|
||||
|
||||
if (deleted.length > 0) {
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async addMember(
|
||||
fleetId: number,
|
||||
hostId: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const existing = await this.context.drizzle
|
||||
.select({ id: fleetMembers.id })
|
||||
.from(fleetMembers)
|
||||
.where(
|
||||
and(eq(fleetMembers.fleetId, fleetId), eq(fleetMembers.hostId, hostId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) return;
|
||||
|
||||
await this.context.drizzle.insert(fleetMembers).values({
|
||||
fleetId,
|
||||
hostId,
|
||||
addedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async removeMember(fleetId: number, hostId: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fleetMembers)
|
||||
.where(
|
||||
and(eq(fleetMembers.fleetId, fleetId), eq(fleetMembers.hostId, hostId)),
|
||||
);
|
||||
|
||||
const affected = rowsAffected(result) > 0;
|
||||
if (affected) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async listStaticMemberIds(fleetId: number): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ hostId: fleetMembers.hostId })
|
||||
.from(fleetMembers)
|
||||
.where(eq(fleetMembers.fleetId, fleetId));
|
||||
return rows.map((r) => r.hostId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective membership = static fleetMembers rows union hosts whose
|
||||
* comma-separated tags string intersects any tag in the fleet's tagRules.
|
||||
* Both sets are scoped to the fleet owner's hosts and deduplicated by id.
|
||||
*/
|
||||
async listEffectiveMembers(
|
||||
userId: string,
|
||||
fleetId: number,
|
||||
): Promise<FleetMemberHostRecord[]> {
|
||||
const fleet = await this.findById(userId, fleetId);
|
||||
if (!fleet) return [];
|
||||
|
||||
const staticIds = await this.listStaticMemberIds(fleetId);
|
||||
const tagRules = parseTagRules(fleet.tagRules);
|
||||
|
||||
const ownedHosts = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
|
||||
const byId = new Map<number, FleetMemberHostRecord>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Static members can reference hosts the fleet owner no longer owns
|
||||
// (rare, but the FK cascade only fires on host delete, not transfer) -
|
||||
// filter those out rather than surface a partial/foreign row.
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const userFleets = await this.listByUser(userId);
|
||||
const result = await this.context.drizzle
|
||||
.delete(fleets)
|
||||
.where(eq(fleets.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return userFleets.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* ctx.hosts: the hosts a plugin's acting user can see, and the sharing
|
||||
* operations that need core's RBAC and secret-snapshot machinery.
|
||||
*
|
||||
* list/get/checkAccess need hosts:read. share and the share-target pickers
|
||||
* need hosts:write, because granting access to a host is a write on that
|
||||
* host even though the plugin owns neither the host nor the grant.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginHosts,
|
||||
PluginHostSummary,
|
||||
PluginHostAccess,
|
||||
PluginHostShareLevel,
|
||||
PluginHostShareResult,
|
||||
PluginShareTarget,
|
||||
PluginShareableUser,
|
||||
PluginShareableRole,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
details: string,
|
||||
outcome: { success: boolean; errorMessage?: string },
|
||||
) => Promise<void>;
|
||||
|
||||
interface Deps {
|
||||
manifest: PluginManifest;
|
||||
audit: AuditFn;
|
||||
}
|
||||
|
||||
function toSummary(host: {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
tags: string | null;
|
||||
folder: string | null;
|
||||
authType: string;
|
||||
}): PluginHostSummary {
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
tags: host.tags,
|
||||
folder: host.folder,
|
||||
authType: host.authType,
|
||||
};
|
||||
}
|
||||
|
||||
function actingUser(): string {
|
||||
const actor = getActor();
|
||||
if (!actor) {
|
||||
throw new Error(
|
||||
"ctx.hosts needs an acting user: call it inside a request or ctx.asUser",
|
||||
);
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
|
||||
export function createPluginHosts({ manifest, audit }: Deps): PluginHosts {
|
||||
const pluginId = manifest.id;
|
||||
const declared = manifest.capabilities;
|
||||
|
||||
const requireRead = () => assertCapability(pluginId, "hosts:read", declared);
|
||||
const requireWrite = () =>
|
||||
assertCapability(pluginId, "hosts:write", declared);
|
||||
|
||||
return {
|
||||
list: async () => {
|
||||
await requireRead();
|
||||
const userId = actingUser();
|
||||
const {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
} = await import("../database/repositories/factory.js");
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
|
||||
const owned = await repository.findHostsByUserId(userId);
|
||||
const roleIds =
|
||||
await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||
const grants =
|
||||
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
|
||||
userId,
|
||||
roleIds,
|
||||
);
|
||||
const sharedRows = await repository.listHostRowsForAccessList(
|
||||
userId,
|
||||
grants,
|
||||
);
|
||||
|
||||
const byId = new Map<number, PluginHostSummary>();
|
||||
for (const host of owned) byId.set(host.id, toSummary(host));
|
||||
for (const host of sharedRows) {
|
||||
if (!byId.has(host.id)) byId.set(host.id, toSummary(host));
|
||||
}
|
||||
return [...byId.values()];
|
||||
},
|
||||
|
||||
get: async (hostId) => {
|
||||
await requireRead();
|
||||
const userId = actingUser();
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) return null;
|
||||
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const ownerId =
|
||||
(await createCurrentHostResolutionRepository().findHostOwnerId(
|
||||
hostId,
|
||||
)) ?? userId;
|
||||
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||
hostId,
|
||||
ownerId,
|
||||
);
|
||||
return host ? toSummary(host) : null;
|
||||
},
|
||||
|
||||
checkAccess: async (
|
||||
hostId: number,
|
||||
level: PluginHostShareLevel,
|
||||
): Promise<PluginHostAccess> => {
|
||||
await requireRead();
|
||||
const userId = actingUser();
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
level,
|
||||
);
|
||||
return {
|
||||
hasAccess: access.hasAccess,
|
||||
isOwner: access.isOwner,
|
||||
isShared: access.isShared,
|
||||
permissionLevel: access.permissionLevel as
|
||||
PluginHostShareLevel | undefined,
|
||||
expiresAt: access.expiresAt,
|
||||
};
|
||||
},
|
||||
|
||||
share: async (
|
||||
hostId: number,
|
||||
targets: PluginShareTarget[],
|
||||
permissionLevel: PluginHostShareLevel,
|
||||
durationHours?: number,
|
||||
): Promise<PluginHostShareResult> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
} catch (error) {
|
||||
await audit("hosts_share", `host ${hostId}`, {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const userId = actingUser();
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"manage",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
const result: PluginHostShareResult = {
|
||||
hostId,
|
||||
shared: false,
|
||||
reason: "forbidden",
|
||||
};
|
||||
await audit("hosts_share", `host ${hostId}`, {
|
||||
success: false,
|
||||
errorMessage: "forbidden",
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
} = await import("../database/repositories/factory.js");
|
||||
const ownerId =
|
||||
(await createCurrentHostResolutionRepository().findHostOwnerId(
|
||||
hostId,
|
||||
)) ?? userId;
|
||||
|
||||
if (targets.some((t) => t.type === "user" && t.id === ownerId)) {
|
||||
await audit("hosts_share", `host ${hostId}`, {
|
||||
success: false,
|
||||
errorMessage: "owner",
|
||||
});
|
||||
return { hostId, shared: false, reason: "owner" };
|
||||
}
|
||||
|
||||
const expiresAt =
|
||||
durationHours && durationHours > 0
|
||||
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
const secretsManager = SharedHostSecretsManager.getInstance();
|
||||
|
||||
for (const target of targets) {
|
||||
const grant = await rbacAccessRepository.upsertHostAccess({
|
||||
hostId,
|
||||
grantedBy: userId,
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
...(target.type === "user"
|
||||
? { targetType: "user" as const, targetUserId: target.id as string }
|
||||
: {
|
||||
targetType: "role" as const,
|
||||
targetRoleId: target.id as number,
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
if (target.type === "user") {
|
||||
await secretsManager.snapshotForUser(
|
||||
grant.id,
|
||||
hostId,
|
||||
target.id as string,
|
||||
ownerId,
|
||||
);
|
||||
} else {
|
||||
await secretsManager.snapshotForRole(
|
||||
grant.id,
|
||||
hostId,
|
||||
target.id as number,
|
||||
ownerId,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// A snapshot failure never blocks the grant: the recipient can
|
||||
// still be prompted, or the owner can retry sharing later.
|
||||
}
|
||||
}
|
||||
|
||||
await audit("hosts_share", `host ${hostId}`, { success: true });
|
||||
return { hostId, shared: true };
|
||||
},
|
||||
|
||||
listUsers: async (): Promise<PluginShareableUser[]> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
} catch (error) {
|
||||
await audit("hosts_list_users", "share target picker", {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const { createCurrentUserRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
await audit("hosts_list_users", "share target picker", {
|
||||
success: true,
|
||||
});
|
||||
return users.map((u) => ({ id: u.id, username: u.username }));
|
||||
},
|
||||
|
||||
listRoles: async (): Promise<PluginShareableRole[]> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
} catch (error) {
|
||||
await audit("hosts_list_roles", "share target picker", {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const { createCurrentRoleRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const roles = await createCurrentRoleRepository().listRoles();
|
||||
await audit("hosts_list_roles", "share target picker", {
|
||||
success: true,
|
||||
});
|
||||
return roles
|
||||
.filter((r) => !r.isSystem)
|
||||
.map((r) => ({ id: r.id, name: r.name, displayName: r.displayName }));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
resolveDatabaseDialect,
|
||||
} from "../database/db/dialect.js";
|
||||
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
|
||||
import { createPluginHosts } from "./ctx-hosts.js";
|
||||
|
||||
export type { PluginModule };
|
||||
|
||||
@@ -608,6 +609,7 @@ export function createPluginContext(
|
||||
},
|
||||
},
|
||||
|
||||
hosts: createPluginHosts({ manifest, audit: auditCall }),
|
||||
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
granted: new Set<string>(),
|
||||
actor: undefined as string | undefined,
|
||||
access: {
|
||||
hasAccess: true,
|
||||
isOwner: true,
|
||||
isShared: false,
|
||||
} as Record<string, unknown>,
|
||||
ownedHosts: [] as Array<Record<string, unknown>>,
|
||||
sharedRows: [] as Array<Record<string, unknown>>,
|
||||
visibleGrants: [] as Array<{ hostId: number }>,
|
||||
ownerId: "user-1",
|
||||
hostById: null as Record<string, unknown> | null,
|
||||
upserts: [] as Array<Record<string, unknown>>,
|
||||
users: [] as Array<{ id: string; username: string }>,
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
displayName: string | null;
|
||||
isSystem: boolean;
|
||||
}>,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async () => h.access,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostsByUserId: async () => h.ownedHosts,
|
||||
listHostRowsForAccessList: async () => h.sharedRows,
|
||||
findHostOwnerId: async () => h.ownerId,
|
||||
findHostById: async () => h.hostById,
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
listUserRoleIds: async () => [],
|
||||
listRoles: async () => h.roles,
|
||||
}),
|
||||
createCurrentRbacAccessRepository: () => ({
|
||||
listVisibleHostAccessEntries: async () => h.visibleGrants,
|
||||
upsertHostAccess: async (input: Record<string, unknown>) => {
|
||||
h.upserts.push(input);
|
||||
return { id: h.upserts.length, created: true };
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => h.users,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
snapshotForUser: async () => {},
|
||||
snapshotForRole: async () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
function manifest(capabilities: string[]) {
|
||||
return {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.granted = new Set();
|
||||
h.actor = "user-1";
|
||||
h.access = { hasAccess: true, isOwner: true, isShared: false };
|
||||
h.ownedHosts = [];
|
||||
h.sharedRows = [];
|
||||
h.visibleGrants = [];
|
||||
h.ownerId = "user-1";
|
||||
h.hostById = null;
|
||||
h.upserts = [];
|
||||
h.users = [];
|
||||
h.roles = [];
|
||||
});
|
||||
|
||||
describe("ctx.hosts", () => {
|
||||
it("refuses list/get/checkAccess without hosts:read", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
||||
await expect(hosts.list()).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
await expect(hosts.get(1)).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
await expect(hosts.checkAccess(1, "view")).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("lists owned and shared hosts once granted hosts:read", async () => {
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
h.ownedHosts = [
|
||||
{
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
name: "own",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: "prod",
|
||||
folder: null,
|
||||
authType: "password",
|
||||
},
|
||||
];
|
||||
h.sharedRows = [
|
||||
{
|
||||
id: 2,
|
||||
userId: "user-2",
|
||||
name: "shared",
|
||||
ip: "10.0.0.2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "key",
|
||||
},
|
||||
];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const list = await hosts.list();
|
||||
expect(list.map((h2) => h2.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("checkAccess reports the granted level", async () => {
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
h.access = {
|
||||
hasAccess: true,
|
||||
isOwner: false,
|
||||
isShared: true,
|
||||
permissionLevel: "edit",
|
||||
expiresAt: null,
|
||||
};
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const access = await hosts.checkAccess(1, "edit");
|
||||
expect(access).toMatchObject({ hasAccess: true, permissionLevel: "edit" });
|
||||
});
|
||||
|
||||
it("refuses share, listUsers, listRoles without hosts:write, auditing the refusal", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
||||
await expect(
|
||||
hosts.share(1, [{ type: "user", id: "user-2" }], "view"),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
await expect(hosts.listUsers()).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
await expect(hosts.listRoles()).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"hosts_share",
|
||||
expect.any(String),
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shares a host it manages and snapshots secrets per target", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.access = { hasAccess: true, isOwner: true, isShared: false };
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const result = await hosts.share(
|
||||
5,
|
||||
[{ type: "user", id: "user-2" }],
|
||||
"view",
|
||||
24,
|
||||
);
|
||||
expect(result).toEqual({ hostId: 5, shared: true });
|
||||
expect(h.upserts).toHaveLength(1);
|
||||
expect(h.upserts[0]).toMatchObject({
|
||||
hostId: 5,
|
||||
permissionLevel: "view",
|
||||
targetType: "user",
|
||||
targetUserId: "user-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to share a host the caller does not manage", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.access = { hasAccess: false, isOwner: false, isShared: false };
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const result = await hosts.share(
|
||||
5,
|
||||
[{ type: "user", id: "user-2" }],
|
||||
"view",
|
||||
);
|
||||
expect(result).toEqual({ hostId: 5, shared: false, reason: "forbidden" });
|
||||
});
|
||||
|
||||
it("lists non-system roles and users once granted hosts:write", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.users = [{ id: "user-1", username: "alice" }];
|
||||
h.roles = [
|
||||
{ id: 1, name: "admin", displayName: "Admin", isSystem: true },
|
||||
{ id: 2, name: "auditor", displayName: "Auditor", isSystem: false },
|
||||
];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(await hosts.listUsers()).toEqual([
|
||||
{ id: "user-1", username: "alice" },
|
||||
]);
|
||||
expect(await hosts.listRoles()).toEqual([
|
||||
{ id: 2, name: "auditor", displayName: "Auditor" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parsePlatformProbe,
|
||||
buildPackageActionCommand,
|
||||
buildPackageRemoveCommand,
|
||||
isValidPackageName,
|
||||
buildSudoCommand,
|
||||
parseUpgradable,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
|
||||
describe("parsePlatformProbe", () => {
|
||||
it("prefers dnf over yum when both are present", () => {
|
||||
const out = ["dnf=1", "yum=1", "apt=0", "pacman=0"].join("\n");
|
||||
expect(parsePlatformProbe(out).pkg).toBe("dnf");
|
||||
});
|
||||
|
||||
it("reads the pretty OS name and tool flags", () => {
|
||||
const out = [
|
||||
"systemd=1",
|
||||
"apt=1",
|
||||
"dnf=0",
|
||||
"yum=0",
|
||||
"pacman=0",
|
||||
"certbot=1",
|
||||
"acmesh=0",
|
||||
"docker=1",
|
||||
"os=Debian GNU/Linux 12 (bookworm)",
|
||||
].join("\n");
|
||||
const info = parsePlatformProbe(out);
|
||||
expect(info).toMatchObject({
|
||||
hasSystemd: true,
|
||||
pkg: "apt",
|
||||
hasCertbot: true,
|
||||
hasAcmeSh: false,
|
||||
hasDocker: true,
|
||||
osPrettyName: "Debian GNU/Linux 12 (bookworm)",
|
||||
});
|
||||
});
|
||||
|
||||
it("nulls the OS name when the probe line is empty", () => {
|
||||
expect(parsePlatformProbe("os=").osPrettyName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPackageActionCommand", () => {
|
||||
it("builds install and upgrade-all commands per package manager", () => {
|
||||
expect(buildPackageActionCommand("apt", "install", "curl")).toContain(
|
||||
"apt-get -y install curl",
|
||||
);
|
||||
expect(buildPackageActionCommand("dnf", "upgrade-all")).toBe(
|
||||
"dnf -y upgrade",
|
||||
);
|
||||
expect(buildPackageActionCommand("yum", "install", "curl")).toBe(
|
||||
"yum -y install curl",
|
||||
);
|
||||
expect(buildPackageActionCommand("pacman", "upgrade-all")).toBe(
|
||||
"pacman -Syu --noconfirm",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no package manager was detected", () => {
|
||||
expect(buildPackageActionCommand(null, "install", "curl")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPackageRemoveCommand", () => {
|
||||
it("builds the correct remove command per package manager", () => {
|
||||
expect(buildPackageRemoveCommand("apt", "curl")).toContain(
|
||||
"apt-get -y remove curl",
|
||||
);
|
||||
expect(buildPackageRemoveCommand("dnf", "curl")).toBe("dnf -y remove curl");
|
||||
expect(buildPackageRemoveCommand("yum", "curl")).toBe("yum -y remove curl");
|
||||
expect(buildPackageRemoveCommand("pacman", "curl")).toBe(
|
||||
"pacman -R --noconfirm curl",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no package manager was detected", () => {
|
||||
expect(buildPackageRemoveCommand(null, "curl")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidPackageName", () => {
|
||||
it("accepts ordinary package names", () => {
|
||||
expect(isValidPackageName("curl")).toBe(true);
|
||||
expect(isValidPackageName("lib32-glibc")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects shell metacharacters and empty strings", () => {
|
||||
expect(isValidPackageName("curl; rm -rf /")).toBe(false);
|
||||
expect(isValidPackageName("")).toBe(false);
|
||||
expect(isValidPackageName(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSudoCommand", () => {
|
||||
it("single-quotes the password and wraps the marker echo around the command", () => {
|
||||
const cmd = buildSudoCommand("uptime", "p'w");
|
||||
expect(cmd).toContain("sudo -S -p ''");
|
||||
expect(cmd).toContain("__TX_SUDO_OK__");
|
||||
expect(cmd).toContain("uptime");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUpgradable", () => {
|
||||
it("parses apt list --upgradable output", () => {
|
||||
const out = "curl/stable 7.88.0 amd64 [upgradable from: 7.87.0]";
|
||||
expect(parseUpgradable("apt", out)).toEqual([
|
||||
{ name: "curl", newVersion: "7.88.0", currentVersion: "7.87.0" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list for no package manager", () => {
|
||||
expect(parseUpgradable(null, "anything")).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user