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

+1 -1
View File
@@ -161,7 +161,7 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-6OrZHOmlg8SaaMdXOWFOMsBrosTVDr0JC5JXhs2mSls='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-xKFwPOSyQDCFj701DTxJU9nyOtY2cN1n+z8W2Q5rnUw='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+1 -1
View File
@@ -142,7 +142,7 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-6OrZHOmlg8SaaMdXOWFOMsBrosTVDr0JC5JXhs2mSls='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-xKFwPOSyQDCFj701DTxJU9nyOtY2cN1n+z8W2Q5rnUw='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+2
View File
@@ -0,0 +1,2 @@
-- dropped, see plugin adoption (fleets plugin, migrations/mysql/0001_adopt_fleets.sql)
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -239,6 +239,13 @@
"when": 1790169398523,
"tag": "0033_clear_anthem",
"breakpoints": true
},
{
"idx": 34,
"version": "5",
"when": 1790176259240,
"tag": "0034_green_vector",
"breakpoints": true
}
]
}
+2
View File
@@ -0,0 +1,2 @@
-- dropped, see plugin adoption (fleets plugin, migrations/postgres/0001_adopt_fleets.sql)
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -246,6 +246,13 @@
"when": 1790169398038,
"tag": "0034_free_smiling_tiger",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1790176258755,
"tag": "0035_ambitious_klaw",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
-- dropped, see plugin adoption (fleets plugin, migrations/sqlite/0001_adopt_fleets.sql)
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -218,6 +218,13 @@
"when": 1790169397528,
"tag": "0030_quick_felicia_hardy",
"breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1790176258239,
"tag": "0031_smooth_scarlet_witch",
"breakpoints": true
}
]
}
+40 -13
View File
@@ -26,18 +26,19 @@ This is not a sandbox. See [What this protects](#what-this-protects-and-what-it-
A plugin imports `@termix/plugin-sdk`, its own files and its own npm
dependencies. Entry points:
| Entry | Contents |
| ---------------------------------- | ---------------------------------------------------------- |
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
| `@termix/plugin-sdk/table-builder` | `buildTable()`, a definition as a queryable Drizzle table |
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
| `@termix/plugin-sdk/capabilities` | The capability catalog |
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
| `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more |
| Entry | Contents |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
| `@termix/plugin-sdk/table-builder` | `buildTable()`, a definition as a queryable Drizzle table |
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
| `@termix/plugin-sdk/capabilities` | The capability catalog |
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
| `@termix/plugin-sdk/host-commands` | Platform detection, package manager commands, sudo elevation - pure helpers over an ssh2 `Client` a plugin got from `ctx.ssh` |
| `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more |
Plugins never import from `src/` or `@/`, and core never imports from
`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK
@@ -1045,7 +1046,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.*` | `notify:send` | A6 |
@@ -1073,6 +1074,32 @@ connect pipeline instead of importing ssh2 helpers from core:
Each new connection is audited and runs as the current actor; pooled reuse is
not. Connections and pool entries are closed on deactivate.
**B4** added `ctx.hosts`, for a plugin that groups or acts across hosts it
does not own the way fleets does:
- `list()` / `get(hostId)` return `PluginHostSummary` (id, userId, name, ip,
port, username, tags, folder, authType - no secrets) for the acting user's
own and shared hosts. Needs `hosts:read`.
- `checkAccess(hostId, level)` mirrors `PermissionManager.canAccessHost`,
naming the level ("connect" | "view" | "edit" | "manage") rather than a
bare yes/no, because a plugin fanning out across a fleet needs to know
whether it can only connect, or also manage. Needs `hosts:read`.
- `share(hostId, targets, permissionLevel, durationHours)` grants access the
same way the host editor's own share action does, snapshotting shared
secrets per target through core's `SharedHostSecretsManager`. Refuses a
host the caller does not hold "manage" on. Needs `hosts:write`, because
granting access to a host is a write on that host even when the plugin
owns neither the host nor the grant.
- `listUsers()` / `listRoles()` return minimal `{ id, username }` /
`{ id, name, displayName }` lists (roles filtered to non-system) for a
share-target picker UI, without exposing the full admin user/role
management surface. Needs `hosts:write`, matching `share`.
`ctx.hosts.get` returns the same non-secret projection as `list`; a plugin
that needs to actually connect uses `ctx.ssh.connect(hostId)` or
`withConnection(hostId, ...)`, which resolve the full host (secrets included)
through the one connect pipeline. fleets never holds a `PluginSshHost` itself.
### The actor
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
+10 -4
View File
@@ -13,7 +13,13 @@ it's done.
- **B3 (tailscale):** `plugins/tailscale/src/backend/host-metrics-manager.ts`
and `routes.ts` still resolve hosts and access through core's
`PermissionManager`/`resolveHostById`/`DataCrypto` by relative import
instead of a `ctx.hosts`-style SDK API, because `ctx.hosts` is not built yet
(status **B** in the ctx surface table). Same debt every other host-metrics
manager already carries; not new, but tailscale now carries it too. Owner:
whichever step builds `ctx.hosts`.
instead of `ctx.hosts`, which **B4** built. Same debt every other
host-metrics manager already carries; not new, but tailscale now carries it
too. Owner: host-metrics's own Phase B step (it and every manager plugin
convert together).
- **B4 (fleets/host-metrics):** `src/backend/hosts/metrics-shared/` (platform
detection, package commands, exec-elevated) is still core's own copy, used
by host-metrics, which has not converted yet. `@termix/plugin-sdk/host-commands`
is the SDK copy fleets now uses; core's copy was left in place rather than
turned into a re-export, since host-metrics's own conversion step is where
its imports should move wholesale. Owner: host-metrics's Phase B step.
+9 -1
View File
@@ -52,6 +52,10 @@
"types": "./dist/vitest-preset.d.ts",
"default": "./dist/vitest-preset.js"
},
"./host-commands": {
"types": "./dist/host-commands.d.ts",
"default": "./dist/host-commands.js"
},
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
"./schema/manifest.schema.json": "./schema/manifest.schema.json"
},
@@ -71,11 +75,15 @@
"tar": "^7.5.22"
},
"peerDependencies": {
"drizzle-orm": "*"
"drizzle-orm": "*",
"ssh2": "*"
},
"peerDependenciesMeta": {
"drizzle-orm": {
"optional": true
},
"ssh2": {
"optional": true
}
}
}
+86
View File
@@ -395,6 +395,90 @@ export interface PluginSettings {
readCore: (key: string) => Promise<string | null>;
}
/** A host's non-secret fields, for listing, tag matching and display. */
export interface PluginHostSummary {
id: number;
userId: string;
name: string | null;
ip: string;
port: number;
username: string;
/** Comma-separated, as ssh_data stores them. Split on "," to match tags. */
tags: string | null;
folder: string | null;
authType: string;
}
/** The same shape canAccessHost returns internally, without secrets. */
export interface PluginHostAccess {
hasAccess: boolean;
isOwner: boolean;
isShared: boolean;
permissionLevel?: "connect" | "view" | "edit" | "manage";
expiresAt?: string | null;
}
export type PluginHostShareLevel = "connect" | "view" | "edit" | "manage";
export interface PluginShareTarget {
type: "user" | "role";
id: string | number;
}
export interface PluginHostShareResult {
hostId: number;
shared: boolean;
reason?: string;
}
/** A user or role a plugin can offer as a share target. No secrets, no roles' permission lists. */
export interface PluginShareableUser {
id: string;
username: string;
}
export interface PluginShareableRole {
id: number;
name: string;
displayName: string | null;
}
/**
* Hosts the acting user can see, and the RBAC + sharing operations a plugin
* needs to build a feature (like fleets) on top of hosts it does not own.
*
* list/get/checkAccess need hosts:read. share, and the user/role pickers a
* share UI needs, need hosts:write: sharing changes who can reach a host,
* which is a write on that host's access, even though the plugin owns
* neither the host nor the grant.
*/
export interface PluginHosts {
/** Every host the acting user owns or was shared, without secrets. */
list: () => Promise<PluginHostSummary[]>;
/** One host the acting user can see, or null if it does not exist or they cannot. */
get: (hostId: number) => Promise<PluginHostSummary | null>;
/** The same access rule ctx.ssh.connect enforces, but naming the level, not just yes/no. */
checkAccess: (
hostId: number,
level: PluginHostShareLevel,
) => Promise<PluginHostAccess>;
/**
* Grants access to a host the caller manages, snapshotting shared secrets
* for each target the same way the host editor's own share action does.
* Refuses a host the caller does not hold "manage" on.
*/
share: (
hostId: number,
targets: PluginShareTarget[],
permissionLevel: PluginHostShareLevel,
durationHours?: number,
) => Promise<PluginHostShareResult>;
/** Users the caller may pick as a share target, for building a picker UI. */
listUsers: () => Promise<PluginShareableUser[]>;
/** Non-system roles the caller may pick as a share target. */
listRoles: () => Promise<PluginShareableRole[]>;
}
/**
* A host as the SSH pipeline reads it: resolved by core, with secrets filled
* in for the acting user. Plugins get one from core and hand it back; they
@@ -814,6 +898,8 @@ export interface PluginContext {
readonly rbac: PluginRbac;
readonly settings: PluginSettings;
readonly disposables: PluginDisposables;
/** Hosts the actor can see, and host-sharing operations. Needs hosts:read / hosts:write. */
readonly hosts: PluginHosts;
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
readonly ssh: PluginSsh;
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
+498
View File
@@ -0,0 +1,498 @@
/**
* Generic host command helpers: platform detection, package manager actions
* and the shell-argument validators that guard them.
*
* Pure functions and thin ssh2 wrappers, no core imports. fleets and
* host-metrics both build their platform/package features on this instead of
* duplicating it or reaching into core.
*
* A plugin passes the `Client` it got from ctx.ssh.connect/withConnection.
*/
export type { Client, ClientChannel } from "ssh2";
import type { Client, ClientChannel } from "ssh2";
export interface ExecResult {
stdout: string;
stderr: string;
code: number | null;
}
/** Runs one non-interactive command over an existing ssh2 client. */
export function execCommand(
client: Client,
command: string,
timeoutMs = 30000,
): Promise<ExecResult> {
return new Promise((resolve, reject) => {
let settled = false;
let stream: ClientChannel | null = null;
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
cleanup();
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
}
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeout);
if (stream) {
try {
stream.removeAllListeners();
if (stream.stderr) {
stream.stderr.removeAllListeners();
}
stream.destroy();
} catch {
// expected - cleanup errors ignored
}
}
};
client.exec(command, { pty: false }, (err, _stream) => {
if (err) {
if (!settled) {
settled = true;
cleanup();
reject(err);
}
return;
}
stream = _stream;
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
stream
.on("close", (code: number | undefined) => {
if (!settled) {
settled = true;
exitCode = typeof code === "number" ? code : null;
cleanup();
resolve({ stdout, stderr, code: exitCode });
}
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
})
.on("error", (streamErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(streamErr);
}
});
if (stream.stderr) {
stream.stderr
.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
})
.on("error", (stderrErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(stderrErr);
}
});
}
});
});
}
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
export interface PlatformInfo {
hasSystemd: boolean;
pkg: PackageManager;
hasCertbot: boolean;
hasAcmeSh: boolean;
hasDocker: boolean;
osPrettyName: string | null;
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
* Extend PATH with their common install locations before probing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
const DOCKER_PROBE =
`command -v docker >/dev/null 2>&1` +
` || [ -S /var/run/docker.sock ]` +
` || [ -S "$HOME/.orbstack/run/docker.sock" ]`;
/**
* Single probe that reports which tooling is available. Each line is
* "key=value" so the parser is trivial and order-independent.
*/
export const PLATFORM_PROBE_COMMAND = [
`PATH="${EXTRA_PATH_DIRS}:$PATH"`,
"echo systemd=$(command -v systemctl >/dev/null 2>&1 && echo 1 || echo 0)",
"echo apt=$(command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0)",
"echo dnf=$(command -v dnf >/dev/null 2>&1 && echo 1 || echo 0)",
"echo yum=$(command -v yum >/dev/null 2>&1 && echo 1 || echo 0)",
"echo pacman=$(command -v pacman >/dev/null 2>&1 && echo 1 || echo 0)",
"echo certbot=$(command -v certbot >/dev/null 2>&1 && echo 1 || echo 0)",
'echo acmesh=$( { command -v acme.sh >/dev/null 2>&1 || [ -x "$HOME/.acme.sh/acme.sh" ]; } && echo 1 || echo 0)',
`echo docker=$(${DOCKER_PROBE} && echo 1 || echo 0)`,
'echo os=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
].join("; ");
export function parsePlatformProbe(output: string): PlatformInfo {
const map = new Map<string, string>();
for (const line of output.split("\n")) {
const idx = line.indexOf("=");
if (idx === -1) continue;
map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
}
const on = (k: string) => map.get(k) === "1";
// Prefer dnf over yum when both exist (dnf is the modern front-end).
let pkg: PackageManager = null;
if (on("apt")) pkg = "apt";
else if (on("dnf")) pkg = "dnf";
else if (on("yum")) pkg = "yum";
else if (on("pacman")) pkg = "pacman";
const os = map.get("os");
return {
hasSystemd: on("systemd"),
pkg,
hasCertbot: on("certbot"),
hasAcmeSh: on("acmesh"),
hasDocker: on("docker"),
osPrettyName: os && os.length > 0 ? os : null,
};
}
export async function detectPlatform(client: Client): Promise<PlatformInfo> {
const { stdout } = await execCommand(client, PLATFORM_PROBE_COMMAND, 15000);
return parsePlatformProbe(stdout);
}
export type HostPlatform = "darwin" | "linux" | "windows" | "other";
// Windows OpenSSH's default shell is usually cmd.exe (occasionally
// PowerShell), so scripts are sent base64-encoded via -EncodedCommand -
// that avoids every cmd.exe quoting/escaping pitfall entirely, since the
// argument ends up being plain alphanumeric text with no shell metacharacters.
export function encodePowerShellCommand(script: string): string {
return Buffer.from(script, "utf16le").toString("base64");
}
export function execPowerShell(
client: Client,
script: string,
timeoutMs = 30000,
): Promise<ExecResult> {
const encoded = encodePowerShellCommand(script);
return execCommand(
client,
`powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
timeoutMs,
);
}
export async function detectHostPlatform(
client: Client,
): Promise<HostPlatform> {
try {
const { stdout, code } = await execCommand(client, "uname -s", 10000);
const kernel = stdout.trim().toLowerCase();
if (kernel === "darwin") return "darwin";
if (kernel === "linux") return "linux";
if (code === 0 && kernel) return "other";
} catch {
// expected on hosts with no POSIX shell (e.g. Windows via cmd.exe)
}
try {
const { stdout } = await execPowerShell(client, "'WIN_OK'", 10000);
if (stdout.includes("WIN_OK")) return "windows";
} catch {
// expected on hosts with no PowerShell available
}
return "other";
}
export interface UpgradablePackage {
name: string;
currentVersion?: string;
newVersion?: string;
}
export function buildListUpgradableCommand(pkg: PackageManager): string | null {
switch (pkg) {
case "apt":
return "apt list --upgradable 2>/dev/null | tail -n +2";
case "dnf":
return "dnf -q check-update 2>/dev/null || true";
case "yum":
return "yum -q check-update 2>/dev/null || true";
case "pacman":
return "pacman -Qu 2>/dev/null || true";
default:
return null;
}
}
export function parseUpgradable(
pkg: PackageManager,
output: string,
): UpgradablePackage[] {
const out: UpgradablePackage[] = [];
const lines = output
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (pkg === "apt") {
for (const line of lines) {
// name/suite newver arch [upgradable from: oldver]
const m = line.match(
/^([^/\s]+)\/\S+\s+(\S+)\s+\S+(?:\s+\[upgradable from:\s+(\S+)\])?/,
);
if (m) out.push({ name: m[1], newVersion: m[2], currentVersion: m[3] });
}
} else if (pkg === "dnf" || pkg === "yum") {
for (const line of lines) {
if (/^(Last metadata|Obsoleting|Security:)/i.test(line)) continue;
const m = line.match(/^(\S+)\s+(\S+)\s+\S+$/);
if (m && m[1].includes(".")) out.push({ name: m[1], newVersion: m[2] });
}
} else if (pkg === "pacman") {
for (const line of lines) {
const m = line.match(/^(\S+)\s+(\S+)\s+->\s+(\S+)$/);
if (m) out.push({ name: m[1], currentVersion: m[2], newVersion: m[3] });
}
}
return out;
}
export type PackageAction = "upgrade-all" | "install" | "upgrade";
export function buildPackageActionCommand(
pkg: PackageManager,
action: PackageAction,
name?: string,
): string | null {
const target = name ? ` ${name}` : "";
switch (pkg) {
case "apt":
if (action === "upgrade-all")
return "DEBIAN_FRONTEND=noninteractive apt-get -y upgrade";
return `DEBIAN_FRONTEND=noninteractive apt-get -y install${target}`;
case "dnf":
return action === "upgrade-all"
? "dnf -y upgrade"
: `dnf -y install${target}`;
case "yum":
return action === "upgrade-all"
? "yum -y update"
: `yum -y install${target}`;
case "pacman":
return action === "upgrade-all"
? "pacman -Syu --noconfirm"
: `pacman -S --noconfirm${target}`;
default:
return null;
}
}
export function buildPackageRemoveCommand(
pkg: PackageManager,
name: string,
): string | null {
switch (pkg) {
case "apt":
return `DEBIAN_FRONTEND=noninteractive apt-get -y remove ${name}`;
case "dnf":
return `dnf -y remove ${name}`;
case "yum":
return `yum -y remove ${name}`;
case "pacman":
return `pacman -R --noconfirm ${name}`;
default:
return null;
}
}
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
export function isValidPackageName(pkg: unknown): pkg is string {
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
}
export type ElevationErrorCode = "SUDO_REQUIRED" | "SUDO_FAILED" | "NOT_SUDOER";
export class ElevationError extends Error {
code: ElevationErrorCode;
constructor(code: ElevationErrorCode, message: string) {
super(message);
this.name = "ElevationError";
this.code = code;
}
}
export interface ElevatedResult {
stdout: string;
stderr: string;
code: number | null;
usedSudo: boolean;
}
const PERMISSION_DENIED = [
"permission denied",
"operation not permitted",
"must be run as root",
"must be superuser",
"you need to be root",
"are you root",
"access denied",
];
/**
* Phrases sudo itself prints to STDERR when authentication or authorization
* fails. Matched only against sudo's own stderr (never command output), so a
* command that happens to print "permission denied" on stdout is not mistaken
* for a sudo failure.
*/
const SUDO_AUTH_FAILED = [
"incorrect password",
"a password is required",
"a terminal is required",
"no tty present",
"sorry, try again",
"no password was provided",
"1 incorrect password attempt",
];
const SUDO_NOT_SUDOER = [
"is not in the sudoers file",
"not allowed to run sudo",
"not allowed to execute",
];
/**
* Marker printed only after sudo has successfully authenticated and started
* the inner shell. Its presence on stdout is the authoritative "elevation
* worked" signal; its absence (together with sudo stderr) means auth failed.
*/
const SUDO_OK_MARKER = "__TX_SUDO_OK__";
const SUDO_OK_LINE_RE = new RegExp(`^${SUDO_OK_MARKER}\\r?\\n?`);
/** Escape a value for single-quoted shell context. */
export function shellSingleQuote(value: string): string {
return `'${value.replace(/'/g, "'\"'\"'")}'`;
}
/**
* Build the elevated command string:
* echo '<pw>' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; <command>'
*
* `-p ''` suppresses the prompt. stderr is NOT merged into stdout, so the
* command's own output stays clean and sudo's auth errors stay on stderr. The
* marker is echoed by the inner shell once sudo authenticates, letting us
* tell a genuine auth failure from command output that merely contains scary
* words.
*/
export function buildSudoCommand(
command: string,
sudoPassword: string,
): string {
const pw = shellSingleQuote(sudoPassword);
const inner = shellSingleQuote(`echo ${SUDO_OK_MARKER}; ${command}`);
return `echo ${pw} | sudo -S -p '' sh -c ${inner}`;
}
function includesAny(text: string, needles: string[]): boolean {
const lower = text.toLowerCase();
return needles.some((n) => lower.includes(n));
}
function looksLikePermissionDenied(text: string): boolean {
return includesAny(text, PERMISSION_DENIED);
}
/** Strip the success marker line from the front of stdout. */
function stripMarker(stdout: string): string {
return stdout.replace(SUDO_OK_LINE_RE, "");
}
/**
* Run a command on a client, elevating with the host's stored sudo password
* only when needed (or when forced). Throws a typed `ElevationError` when
* elevation is required but unavailable/incorrect.
*/
export async function execElevated(
client: Client,
command: string,
sudoPassword: string | undefined,
opts: { forceSudo?: boolean; timeoutMs?: number } = {},
): Promise<ElevatedResult> {
const timeoutMs = opts.timeoutMs ?? 30000;
if (!opts.forceSudo) {
const direct = await execCommand(client, command, timeoutMs);
if (direct.code === 0) {
return { ...direct, usedSudo: false };
}
// Only escalate when the failure looks like a privilege problem. A
// permission-denied phrase on stderr (not arbitrary stdout) is the signal.
if (!looksLikePermissionDenied(direct.stderr)) {
return { ...direct, usedSudo: false };
}
if (!sudoPassword) {
throw new ElevationError(
"SUDO_REQUIRED",
"This action requires elevated privileges. Set a sudo password for this host to continue.",
);
}
} else if (!sudoPassword) {
throw new ElevationError(
"SUDO_REQUIRED",
"This action requires elevated privileges. Set a sudo password for this host to continue.",
);
}
const sudoCmd = buildSudoCommand(command, sudoPassword as string);
const result = await execCommand(client, sudoCmd, timeoutMs);
const authenticated = result.stdout.includes(SUDO_OK_MARKER);
if (!authenticated) {
// Elevation never started: diagnose from sudo's stderr only.
const notSudoer = includesAny(result.stderr, SUDO_NOT_SUDOER);
const authFailed = includesAny(result.stderr, SUDO_AUTH_FAILED);
if (notSudoer) {
throw new ElevationError(
"NOT_SUDOER",
"The connected user is not permitted to use sudo on this host.",
);
}
if (authFailed) {
throw new ElevationError(
"SUDO_FAILED",
"Elevation failed. Check the host's sudo password.",
);
}
// No marker and no recognizable sudo error: treat as a generic failure
// but keep the original output so the caller can surface it.
throw new ElevationError(
"SUDO_FAILED",
"Elevation failed. Check the host's sudo password.",
);
}
return {
stdout: stripMarker(result.stdout),
stderr: result.stderr,
code: result.code,
usedSudo: true,
};
}
+77
View File
@@ -23,6 +23,11 @@ import type {
PluginSecondFactor,
PluginSshAuthProvider,
PluginSshHost,
PluginHostSummary,
PluginHostAccess,
PluginHostShareResult,
PluginShareableUser,
PluginShareableRole,
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
@@ -48,6 +53,11 @@ export interface FakeContextOptions {
* when omitted every check passes, so tests that do not care stay simple.
*/
permissions?: string[];
/** Hosts ctx.hosts.list/get/checkAccess answer with. */
hosts?: PluginHostSummary[];
/** Users and roles ctx.hosts.listUsers/listRoles answer with. */
shareableUsers?: PluginShareableUser[];
shareableRoles?: PluginShareableRole[];
}
export interface FakeAuthRegistrations {
@@ -82,6 +92,13 @@ export interface FakePluginContext {
coreSettings: Map<string, string>;
/** Every ctx.ssh.connect and withConnection call, in order. */
sshConnections: Array<{ host: number | PluginSshHost; pool?: string }>;
/** Every ctx.hosts.share call, in order. */
hostShares: Array<{
hostId: number;
targets: unknown[];
permissionLevel: string;
durationHours?: number;
}>;
/** Everything registered through ctx.auth. */
auth: FakeAuthRegistrations;
/** Changes the acting user, as core's request middleware would. */
@@ -153,6 +170,10 @@ export function createFakeContext(
);
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const hostsById = new Map<number, PluginHostSummary>(
(options.hosts ?? []).map((h) => [h.id, h]),
);
const services = new Map<string, object>();
const auth: FakeAuthRegistrations = {
sshAuthProviders: [],
@@ -385,6 +406,34 @@ export function createFakeContext(
disposables,
hosts: {
list: async () => [...hostsById.values()],
get: async (hostId) => hostsById.get(hostId) ?? null,
checkAccess: async (hostId): Promise<PluginHostAccess> => {
const found = hostsById.get(hostId);
if (!found)
return { hasAccess: false, isOwner: false, isShared: false };
const isOwner = found.userId === actor;
return {
hasAccess: true,
isOwner,
isShared: !isOwner,
permissionLevel: "manage",
};
},
share: async (
hostId,
targets,
permissionLevel,
durationHours,
): Promise<PluginHostShareResult> => {
hostShares.push({ hostId, targets, permissionLevel, durationHours });
return { hostId, shared: true };
},
listUsers: async () => options.shareableUsers ?? [],
listRoles: async () => options.shareableRoles ?? [],
},
ssh: {
connect: async (host) => {
sshConnections.push({ host });
@@ -473,6 +522,7 @@ export function createFakeContext(
settings,
coreSettings,
sshConnections,
hostShares,
auth,
setActor: (userId) => {
actor = userId;
@@ -643,6 +693,33 @@ export function createMockCtx(
},
},
hosts: {
list: async () => {
require("hosts:read");
return ctx.hosts.list();
},
get: async (hostId) => {
require("hosts:read");
return ctx.hosts.get(hostId);
},
checkAccess: async (hostId, level) => {
require("hosts:read");
return ctx.hosts.checkAccess(hostId, level);
},
share: async (hostId, targets, permissionLevel, durationHours) => {
require("hosts:write");
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
},
listUsers: async () => {
require("hosts:write");
return ctx.hosts.listUsers();
},
listRoles: async () => {
require("hosts:write");
return ctx.hosts.listRoles();
},
},
ssh: {
...ctx.ssh,
connect: async (host, connectOptions) => {
+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: [] });
});
});
-4
View File
@@ -31,9 +31,6 @@
"plugins/docker/src/frontend/components/LogViewer.tsx",
"plugins/docker/src/frontend/docker-api.ts",
"plugins/docker/src/frontend/index.tsx",
"plugins/fleets/src/frontend/FleetInventoryTab.tsx",
"plugins/fleets/src/frontend/FleetsPanel.tsx",
"plugins/fleets/src/frontend/fleets-api.ts",
"plugins/host-metrics/src/frontend/HostEditorStatsTab.tsx",
"plugins/host-metrics/src/frontend/HostMetricsApp.tsx",
"plugins/host-metrics/src/frontend/HostMetricsTab.tsx",
@@ -136,7 +133,6 @@
"plugins/docker/src/backend/index.ts",
"plugins/docker/src/backend/routes.ts",
"plugins/docker/src/backend/session-manager.ts",
"plugins/fleets/src/backend/routes.ts",
"plugins/host-metrics/src/backend/automation-bridge.ts",
"plugins/host-metrics/src/backend/history-routes.ts",
"plugins/host-metrics/src/backend/managers/cron.ts",
-81
View File
@@ -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",
-75
View File
@@ -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 ---
-75
View File
@@ -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 ---
-75
View File
@@ -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?.();
}
}
+301
View File
@@ -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 }));
},
};
}
+2
View File
@@ -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 }),
+244
View File
@@ -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([]);
});
});