feat: add host-scripts entry to the plugin sdk

This commit is contained in:
LukeGus committed 2026-10-24 14:30:17 -05:00
1 parent 3d92725ce6
commit a4d8a52928
16 files changed
+2882 -11

No files matched your search

@@ -0,0 +1,211 @@
import { describe, expect, it } from "vitest";
import {
classifyRuntimeError,
composeArgsFor,
containerCommand,
countContainers,
diffContainerStates,
dockerArgs,
parseComposeProjects,
parseContainerList,
parseContainerStates,
parseContainerStats,
parseRuntimeVersion,
parseServices,
parseStackStatus,
stackStateFromServices,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("containerCommand", () => {
it("extends PATH on POSIX hosts", () => {
const command = containerCommand("podman", "ps");
expect(command.startsWith('PATH="')).toBe(true);
expect(command.endsWith(" podman ps")).toBe(true);
});
it("runs the plain CLI on Windows hosts", () => {
expect(containerCommand("docker", "ps -a", true)).toBe("docker ps -a");
});
it("falls back to docker for an unknown runtime", () => {
expect(containerCommand(undefined, "ps", true)).toBe("docker ps");
});
});
describe("dockerArgs", () => {
it("builds the container commands", () => {
expect(dockerArgs.inspect("web")).toBe("inspect web");
expect(dockerArgs.action("restart", "web")).toBe("restart web");
expect(dockerArgs.remove("web", true)).toBe("rm -f web");
expect(dockerArgs.list(true)).toContain("ps -a --format '{");
expect(dockerArgs.stats("web")).toContain("stats web --no-stream");
});
it("refuses container ids that could break the command line", () => {
expect(() => dockerArgs.inspect("web; rm -rf /")).toThrow();
expect(() => dockerArgs.logs("$(id)")).toThrow();
expect(() => dockerArgs.action("restart", "-rf")).toThrow();
expect(() => dockerArgs.action("kill" as never, "web")).toThrow();
});
it("only passes sane log filters", () => {
expect(
dockerArgs.logs("web", {
tail: 50.7,
timestamps: true,
since: "2026-10-01T00:00:00Z",
}),
).toBe("logs web --tail 50 --timestamps --since 2026-10-01T00:00:00Z 2>&1");
expect(dockerArgs.logs("web", { since: "1h; id" })).toBe("logs web 2>&1");
});
});
describe("docker parsing", () => {
it("reads the version and sorts errors", () => {
expect(parseRuntimeVersion("Docker version 27.3.1, build ce12230")).toBe(
"27.3.1",
);
expect(parseRuntimeVersion("podman version 5.2.0")).toBe("5.2.0");
expect(parseRuntimeVersion("nope")).toBe("unknown");
expect(classifyRuntimeError("bash: docker: command not found")).toBe(
"NOT_INSTALLED",
);
expect(
classifyRuntimeError(
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
),
).toBe("DAEMON_NOT_RUNNING");
expect(
classifyRuntimeError("permission denied while trying to connect"),
).toBe("PERMISSION_DENIED");
expect(classifyRuntimeError("something else")).toBe("DOCKER_ERROR");
});
it("skips lines that are not JSON in the list", () => {
const rows = parseContainerList(
'{"id":"a","name":"web"}\nWARNING: x\n{"id":"b","name":"db"}\n',
);
expect(rows.map((r) => r.name)).toEqual(["web", "db"]);
});
it("splits the stats columns", () => {
const stats = parseContainerStats(
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
);
expect(stats).toMatchObject({
cpu: "1.5%",
memoryUsed: "10MiB",
memoryLimit: "1GiB",
netInput: "1kB",
netOutput: "2kB",
pids: "3",
});
});
const ps = [
`{"name":"web","state":"running","status":"Up 2 hours"}`,
`{"name":"db","state":"running","status":"Up 2 hours (unhealthy)"}`,
`{"name":"old","state":"exited","status":"Exited (0)"}`,
].join("\n");
it("counts running, stopped and unhealthy containers", () => {
expect(countContainers(ps)).toEqual({
running: 2,
stopped: 1,
unhealthy: 1,
total: 3,
});
});
it("turns two snapshots into events", () => {
const before = parseContainerStates(ps);
const after = parseContainerStates(
[
`{"name":"web","state":"exited","status":"Exited (1)"}`,
`{"name":"db","state":"running","status":"Up (unhealthy)"}`,
`{"name":"old","state":"running","status":"Up 1 second"}`,
`{"name":"new","state":"running","status":"Up"}`,
].join("\n"),
);
expect(diffContainerStates(before, after)).toEqual([
{ container: "web", event: "exited" },
{ container: "old", event: "started" },
]);
});
});
describe("compose", () => {
it("reads stack status text", () => {
expect(parseStackStatus("running(2), exited(1)")).toEqual({
state: "partial",
running: 2,
total: 3,
});
expect(parseStackStatus("paused(2)")).toMatchObject({ state: "paused" });
expect(parseStackStatus("")).toMatchObject({ state: "not-deployed" });
});
it("reads projects from compose ls", () => {
const stacks = parseComposeProjects(
JSON.stringify([
{
Name: "web",
Status: "running(2)",
ConfigFiles: "/opt/stacks/web/compose.yaml",
},
{ Name: "Bad Name", Status: "running(1)", ConfigFiles: "" },
]),
);
expect(stacks).toEqual([
{
name: "web",
managed: false,
state: "running",
running: 2,
total: 2,
composeFile: "/opt/stacks/web/compose.yaml",
},
]);
});
it("reads services from compose ps, one line or one array", () => {
const row = {
Service: "app",
Name: "web-app-1",
ID: "abc",
Image: "nginx",
State: "running",
Status: "Up",
Health: "",
Publishers: [
{ PublishedPort: 8080, TargetPort: 80 },
{ PublishedPort: 8080, TargetPort: 80 },
{ PublishedPort: 0, TargetPort: 443 },
],
};
const lines = parseServices(JSON.stringify(row));
expect(lines[0]).toMatchObject({ service: "app", ports: "8080:80" });
expect(
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
).toHaveLength(2);
expect(
stackStateFromServices(
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
),
).toEqual({
state: "partial",
running: 1,
total: 2,
});
});
it("maps actions to compose commands", () => {
expect(composeArgsFor("redeploy", "app", {})).toEqual([
"pull app",
"up -d --remove-orphans app",
]);
expect(
composeArgsFor("destroy", undefined, { removeVolumes: true }),
).toEqual(["down --remove-orphans -v"]);
});
});
@@ -0,0 +1,400 @@
import { describe, it, expect } from "vitest";
import {
buildFilesystemList,
filterExcludedFilesystems,
findWorstMountIndex,
liveCmd,
mergeMonitoredFilesystems,
parseCpuLine,
parseDfLines,
parseLive,
selectPrimaryFilesystem,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("parseCpuLine", () => {
it("parses a standard /proc/stat cpu line", () => {
// user nice system idle iowait irq softirq
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
expect(result).toBeDefined();
// idle = idle(800) + iowait(30)
expect(result?.idle).toBe(830);
// total = sum of all fields
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
});
it("tolerates leading/trailing whitespace", () => {
const result = parseCpuLine(" cpu 1 2 3 4 ");
expect(result?.total).toBe(10);
expect(result?.idle).toBe(4);
});
it("returns undefined for non-cpu lines", () => {
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
expect(parseCpuLine("intr 12345")).toBeUndefined();
});
it("returns undefined when there are fewer than 4 numeric fields", () => {
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
});
});
describe("parseDfLines", () => {
it("parses df -T -P output into rows", () => {
const output =
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(2);
expect(rows[0].mount).toBe("/");
expect(rows[0].type).toBe("ext4");
expect(rows[1].mount).toBe("/data");
});
it("filters out pseudo filesystems", () => {
const output =
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
"overlay overlay 100 50 50 50% /\n" +
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(1);
expect(rows[0].mount).toBe("/mnt/data");
});
it("captures the filesystem type for network shares", () => {
const output =
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
const rows = parseDfLines(output);
expect(rows[0].type).toBe("nfs4");
expect(rows[1].type).toBe("cifs");
});
it("keeps full image and firmware mounts out of disk usage", () => {
const rows = parseDfLines(
"/dev/loop0 squashfs 100 100 0 100% /snap/core/1\n" +
"efivarfs efivarfs 100 89 11 89% /sys/firmware/efi/efivars\n" +
"/dev/sda1 ext4 1000 400 600 40% /\n",
);
expect(rows.map((row) => row.mount)).toEqual(["/"]);
expect(findWorstMountIndex(rows)).toEqual({
index: 0,
usedBytes: 400,
totalBytes: 1000,
});
});
it("retains real storage without requiring a /dev source", () => {
const rows = parseDfLines(
"/dev/sda1 btrfs 1000 400 600 40% /\n" +
"tank/data zfs 2000 1000 1000 50% /data\n" +
"nas:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
);
expect(rows.map((row) => row.type)).toEqual([
"btrfs",
"zfs",
"nfs4",
"cifs",
]);
});
});
describe("findWorstMountIndex", () => {
it("picks the most-utilized mount, not just the first row", () => {
const rows = parseDfLines(
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
expect(worst.totalBytes).toBe(15393162788864);
expect(worst.usedBytes).toBe(15239230844928);
});
it("falls back to the only mount available", () => {
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(0);
});
it("skips rows with invalid or zero totals", () => {
const rows = parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
"/dev/sda2 ext4 100 40 60 40% /ok\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
});
it("returns index -1 when there are no usable rows", () => {
const worst = findWorstMountIndex([]);
expect(worst.index).toBe(-1);
expect(worst.totalBytes).toBe(0);
});
});
const BYTES_OUTPUT =
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
const HUMAN_OUTPUT =
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
describe("buildFilesystemList", () => {
it("returns every real filesystem with byte maths and human strings", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
mount: "/",
type: "ext4",
percent: 40,
usedHuman: "400",
totalHuman: "1.0K",
availableHuman: "600",
usedBytes: 400,
totalBytes: 1000,
});
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
});
it("matches human rows by mount when the row counts differ", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
);
expect(list[0].totalHuman).toBeNull();
expect(list[1].totalHuman).toBe("2.0K");
});
it("drops filesystems with a zero or invalid total", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
),
[],
);
expect(list).toHaveLength(1);
expect(list[0].mount).toBe("/ok");
});
});
describe("selectPrimaryFilesystem", () => {
it("prefers root over a fuller secondary mount", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
});
it("falls back to the most-utilized mount when there is no root", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
),
[],
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
});
it("returns null for an empty list", () => {
expect(selectPrimaryFilesystem([])).toBeNull();
});
});
describe("filterExcludedFilesystems", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 400 600 40% /\n" +
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
),
[],
);
it("returns the same list when no mounts are excluded", () => {
expect(filterExcludedFilesystems(list)).toHaveLength(3);
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
});
it("excludes an exact mount path match", () => {
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("excludes by filesystem type substring, case-insensitively", () => {
const filtered = filterExcludedFilesystems(list, ["NFS"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("supports excluding multiple network filesystem types at once", () => {
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
});
it("ignores blank/whitespace-only entries", () => {
const filtered = filterExcludedFilesystems(list, [" ", ""]);
expect(filtered).toHaveLength(3);
});
});
describe("mergeMonitoredFilesystems", () => {
it("adds an arbitrary path with a user label", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const custom = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/config", label: "Home Assistant" }],
custom,
);
expect(result).toHaveLength(2);
expect(result[1]).toMatchObject({
mount: "/config",
label: "Home Assistant",
totalBytes: 1000,
});
});
it("labels a path that is already a detected mount", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/data", label: "Media" }],
detected,
);
expect(result).toHaveLength(1);
expect(result[0].label).toBe("Media");
});
});
const out = (
agg: [number, number],
core: [number, number],
rx: number,
up: number,
procs: string,
page = "4096",
) =>
[
`cpu ${agg[0]} 0 0 ${agg[1]} 0 0 0 0 0 0\ncpu0 ${core[0]} 0 0 ${core[1]} 0 0 0 0 0 0`,
"MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB",
`0.1 0.2 0.3 1/2 3\n${up} 5`,
`Inter-|\n eth0: ${rx} 0 0 0 0 0 0 0 ${rx * 2} 0\n lo: 9 0 0 0 0 0 0 0 9 0`,
page,
procs,
].join("\n@@\n");
const proc = (pid: number, ticks: number, start: number, name = "java") =>
`${pid}\t${ticks}\t10\t${start}\tR\t1\t${name}\n`;
describe("parseLive extras", () => {
it("reports state, parent, CPU mix, cache and the running count", () => {
const mk = (user: number, sys: number, wait: number, idle: number) =>
out([user, idle], [user, idle], 1, 10, procs).replace(
/cpu {2}\d+ 0 0 \d+ 0 0 0 0/,
`cpu ${user} 0 ${sys} ${idle} ${wait} 0 0 0`,
);
const procs = "5\t1\t10\t7\tR\t2\tjava\n6\t1\t10\t8\tS\t5\tsh\n";
const a = parseLive(mk(0, 0, 0, 0), undefined, 0);
const b = parseLive(mk(30, 10, 10, 50), a.next, 1000);
expect(b.sample.cpuMix).toMatchObject({ user: 30, system: 10, iowait: 10 });
expect(b.sample.running).toBe(1);
expect(b.sample.procs.find((p) => p.pid === 6)).toMatchObject({
state: "S",
ppid: 5,
});
expect(b.sample.memCachedKb).toBe(0);
});
});
describe("liveCmd", () => {
it("asks for the page size once, then passes the cached number", () => {
expect(liveCmd()).toContain('pg="$(getconf PAGESIZE)"');
expect(liveCmd(16384)).toContain("pg=16384 ");
expect(liveCmd(16384)).not.toContain("getconf");
});
});
describe("parseLive", () => {
it("has no rates on the first sample", () => {
const { sample } = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
undefined,
);
expect(sample.cpu).toBeNull();
expect(sample.net[0].rxBps).toBeNull();
expect(sample.memUsedKb).toBe(600);
});
it("derives cpu, per-core, net and process rates from the delta", () => {
const a = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
undefined,
);
// aggregate: +50 busy, +50 idle; core 0: +25 busy, +75 idle
const { sample } = parseLive(
out([150, 150], [125, 175], 3000, 11, proc(7, 50, 1)),
a.next,
);
expect(sample.cpu).toBeCloseTo(50, 0);
expect(sample.cores[0]).toBeCloseTo(25, 0);
expect(sample.net).toEqual([{ name: "eth0", rxBps: 2000, txBps: 4000 }]);
expect(sample.procs[0]).toMatchObject({ pid: 7, name: "java", memKb: 40 });
expect(sample.procs[0].cpu).toBeCloseTo(50, 0);
});
it("does not charge a reused pid with the old process's ticks", () => {
const a = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 5, 1)),
undefined,
);
// same pid, new start time, lower ticks must not look like a huge delta
const { sample } = parseLive(
out([150, 150], [125, 175], 3000, 11, proc(7, 9000, 2)),
a.next,
);
expect(sample.procs[0].cpu).toBe(0);
});
it("keeps the cached page size when a run omits it, and keeps names with tabs", () => {
const a = parseLive(
out([1, 1], [1, 1], 1, 10, proc(1, 0, 1), "16384"),
undefined,
);
expect(a.next.pageBytes).toBe(16384);
const b = parseLive(
out([2, 2], [2, 2], 2, 11, proc(1, 0, 1, "a b"), ""),
a.next,
);
expect(b.next.pageBytes).toBe(16384);
expect(b.sample.procs[0]).toMatchObject({ name: "a\tb", memKb: 160 });
});
it("starts rates again after a reboot", () => {
const a = parseLive(
out([100, 100], [100, 100], 5000, 900, proc(7, 0, 1)),
undefined,
);
const { sample } = parseLive(
out([10, 10], [10, 10], 20, 5, proc(7, 0, 1)),
a.next,
);
expect(sample.cpu).toBeNull();
expect(sample.net[0].rxBps).toBeNull();
});
});
@@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import {
isUnit,
journalArgs,
parseBoots,
parseDetect,
parseEntry,
parseIsActive,
parseJournal,
parseJournalQuery,
parseOpenRcList,
parseSystemdTime,
parseTimers,
parseUnitList,
parseUsage,
shellQuote as q,
vacuumScript,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("names", () => {
it("quotes for bash", () => {
expect(q("a'b")).toBe(`'a'\\''b'`);
});
it("takes unit names and refuses anything else", () => {
expect(isUnit("nginx.service")).toBe(true);
expect(isUnit("getty@tty1.service")).toBe(true);
expect(isUnit("dev-disk-by\\x2duuid-1.device")).toBe(true);
expect(isUnit("-rf.service")).toBe(false);
expect(isUnit("a;b.service")).toBe(false);
expect(isUnit("a b.service")).toBe(false);
expect(isUnit("nginx")).toBe(false);
expect(isUnit("$(id).service")).toBe(false);
});
});
describe("systemd parsing", () => {
const list = [
"cron.service loaded active running Regular background program",
"● bad.service loaded failed failed Broken thing",
"getty@tty1.service loaded active running Getty on tty1",
"@@TERMIX_FILES@@",
"cron.service enabled enabled",
"bad.service disabled enabled",
"getty@.service enabled enabled",
"old.service masked -",
"",
].join("\n");
it("merges loaded units with unit files and skips templates", () => {
const units = parseUnitList(list);
expect(units.map((u) => u.name)).toEqual([
"bad.service",
"cron.service",
"getty@tty1.service",
"old.service",
]);
const cron = units.find((u) => u.name === "cron.service")!;
expect(cron).toMatchObject({
active: "active",
sub: "running",
description: "Regular background program",
fileState: "enabled",
preset: "enabled",
});
expect(units.find((u) => u.name === "bad.service")!.active).toBe("failed");
expect(units.find((u) => u.name === "old.service")).toMatchObject({
load: "masked",
active: "inactive",
preset: "",
});
});
it("reads UTC times", () => {
expect(parseSystemdTime("Thu 2026-10-22 02:03:51 UTC")).toBe(
Date.UTC(2026, 9, 22, 2, 3, 51),
);
expect(parseSystemdTime("")).toBeNull();
expect(parseSystemdTime("n/a")).toBeNull();
});
it("reads timers from systemctl show blocks", () => {
const out = [
"TimersCalendar={ OnCalendar=*-*-* 06,18:00:00 ; next_elapse=@1792605600 }",
"NextElapseUSecRealtime=Thu 2026-10-22 02:03:51 UTC",
"LastTriggerUSec=Wed 2026-10-21 17:02:58 UTC",
"Id=apt-daily.timer",
"Triggers=apt-daily.service",
"ActiveState=active",
"",
"TimersMonotonic={ OnUnitActiveUSec=1d ; next_elapse=1d }",
"TimersMonotonic={ OnBootUSec=15min ; next_elapse=15min }",
"NextElapseUSecRealtime=",
"LastTriggerUSec=",
"Id=clean.timer",
"Triggers=clean.service",
].join("\n");
const timers = parseTimers(out);
expect(timers[0]).toMatchObject({
name: "apt-daily.timer",
activates: "apt-daily.service",
next: Date.UTC(2026, 9, 22, 2, 3, 51),
schedule: ["OnCalendar=*-*-* 06,18:00:00"],
});
expect(timers[1]).toMatchObject({
name: "clean.timer",
next: null,
last: null,
schedule: ["OnUnitActiveUSec=1d", "OnBootUSec=15min"],
});
});
it("maps is-active lines to units", () => {
const states = parseIsActive(
["a.service", "b.service"],
"active\nfailed\n",
);
expect(states.get("a.service")).toBe("active");
expect(states.get("b.service")).toBe("failed");
});
it("reads what detect prints", () => {
expect(
parseDetect(
"init=systemd\nversion=257\nos=Debian GNU/Linux 13 (trixie)\nuid=0\njournal=1\nfailed=2\n",
),
).toMatchObject({
init: "systemd",
version: "257",
root: true,
journal: true,
failed: 2,
});
expect(parseDetect("init=weird\nuid=1000\n")).toMatchObject({
init: "none",
root: false,
});
});
});
describe("journal", () => {
it("checks filters", () => {
const query = parseJournalQuery({
unit: ["nginx.service", "ssh.service"],
priority: "3",
since: "-1h",
boot: "-1",
grep: "fail'ed",
lines: "99999",
});
expect(query.lines).toBe(5000);
const args = journalArgs(query);
expect(args).toContain("-u 'nginx.service' -u 'ssh.service'");
expect(args).toContain("-p 3");
expect(args).toContain("--since '-1h'");
expect(args).toContain("-b '-1'");
expect(args).toContain(`-g 'fail'\\''ed'`);
expect(() => parseJournalQuery({ unit: "x;rm" })).toThrow();
expect(() => parseJournalQuery({ priority: "9" })).toThrow();
expect(() => parseJournalQuery({ since: "$(id)" })).toThrow();
expect(() => parseJournalQuery({ boot: "abc" })).toThrow();
});
it("parses entries, binary messages included", () => {
const entry = parseEntry(
JSON.stringify({
__REALTIME_TIMESTAMP: "1792612429684000",
PRIORITY: "3",
_SYSTEMD_UNIT: "x.service",
SYSLOG_IDENTIFIER: "x",
_PID: "12",
MESSAGE: [104, 105],
}),
);
expect(entry).toEqual({
time: 1792612429684,
priority: 3,
unit: "x.service",
ident: "x",
pid: "12",
message: "hi",
});
expect(parseEntry("not json")).toBeNull();
});
it("filters here when journalctl has no -g", () => {
const line = (message: string) =>
JSON.stringify({ __REALTIME_TIMESTAMP: "1000", MESSAGE: message });
const out = `@@TERMIX_NOGREP@@\n${line("Disk Error")}\n${line("ok")}\n`;
const query = parseJournalQuery({ grep: "error", lines: "10" });
expect(parseJournal(out, query).map((e) => e.message)).toEqual([
"Disk Error",
]);
});
it("reads boots and disk use", () => {
const boots = parseBoots(
[
"IDX BOOT ID FIRST ENTRY LAST ENTRY",
"-1 d2785a657f4c4561a1f436db27555b50 Tue 2026-10-20 13:56:45 UTC Wed 2026-10-21 10:37:00 UTC",
" 0 61b15588a75b4c0cb8dea321fcc438ab Wed 2026-10-21 10:38:10 UTC Wed 2026-10-21 20:00:00 UTC",
].join("\n"),
);
expect(boots.map((b) => b.offset)).toEqual([0, -1]);
expect(boots[0].first).toBe("Wed 2026-10-21 10:38:10 UTC");
expect(
parseUsage(
"Archived and active journals take up 378.6M in the file system.",
),
).toBe("378.6M");
});
it("only takes sane vacuum values", () => {
expect(vacuumScript({ size: "500M" })).toContain("--vacuum-size=500M");
expect(vacuumScript({ time: "4w" })).toContain("--vacuum-time=4w");
expect(() => vacuumScript({ size: "1; rm -rf /" })).toThrow();
expect(() => vacuumScript({})).toThrow();
});
});
describe("openrc", () => {
it("maps services and runlevels", () => {
const units = parseOpenRcList(
[
" sshd [ started ]",
" crond [ stopped ]",
" nginx [ crashed ]",
"@@TERMIX_RUNLEVELS@@",
" sshd | default",
" nginx | ",
"",
].join("\n"),
);
expect(units.find((u) => u.name === "sshd")).toMatchObject({
active: "active",
fileState: "enabled",
});
expect(units.find((u) => u.name === "nginx")).toMatchObject({
active: "failed",
fileState: "disabled",
});
expect(units.find((u) => u.name === "crond")!.active).toBe("inactive");
});
});
@@ -0,0 +1,130 @@
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
actionScript,
errLine,
isActiveScript,
listUnitsScript,
openRcActionScript,
parseKeys,
passwordSudoScript,
scriptError,
scriptShell,
SUDO_REFUSED_RE,
SUDO_WRONG_RE,
toBase64,
utf8Decode,
utf8Encode,
watchdog,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("text helpers", () => {
it("encodes base64 like Buffer does", () => {
for (const text of [
"",
"a",
"ab",
"abc",
"héllo wörld",
"emoji 🙂 here",
"line\nbreak",
]) {
expect(toBase64(text)).toBe(Buffer.from(text, "utf8").toString("base64"));
}
});
it("round trips UTF-8 and replaces bad bytes", () => {
const text = "plain, ünïcode, 日本, 🙂";
expect(utf8Decode(utf8Encode(text))).toBe(text);
expect(Array.from(utf8Encode(text))).toEqual(
Array.from(Buffer.from(text, "utf8")),
);
expect(utf8Decode([0x61, 0xff, 0x62])).toBe("a\ufffdb");
expect(utf8Decode([0xe6, 0x97])).toBe("\ufffd");
});
it("reads key=value lines", () => {
expect(parseKeys("a=1\nb = two=2\njunk\n")).toEqual({ a: "1", b: "two=2" });
});
});
describe("script shells", () => {
it("always reads the script from stdin", () => {
for (const mode of ["plain", "auto", "root"] as const) {
expect(scriptShell(mode)).toContain("bash -s");
}
expect(scriptShell("auto")).toContain("sudo -n true");
expect(scriptShell("root-only")).toContain("SCRIPT_ERR:");
});
it("collects error lines, old and new markers", () => {
expect(scriptError("x\nSCRIPT_ERR:one\nSERVICES_ERR:two\n")).toBe(
"one\ntwo",
);
expect(errLine("it's broken")).toBe(`echo 'SCRIPT_ERR:it'\\''s broken'`);
});
it("matches sudo refusals", () => {
expect(SUDO_REFUSED_RE.test("sudo: a password is required")).toBe(true);
expect(SUDO_WRONG_RE.test("sudo: 1 incorrect password attempt")).toBe(true);
});
it("keeps the sudo password out of the command line", () => {
const { command, stdin } = passwordSudoScript(
"systemctl restart 'a.service'\n",
"s3cr'et $(id)",
);
expect(command).not.toContain("s3cr");
expect(stdin.split("\n")[0]).toBe("IFS= read -r TERMIX_PW");
expect(stdin.split("\n")[1]).toBe("s3cr'et $(id)");
expect(stdin).toContain("sudo -k -S -p ''");
expect(stdin).not.toContain("systemctl restart");
expect(() => passwordSudoScript("x", "a\nb")).toThrow();
});
it.runIf(process.platform !== "win32")(
"runs the script and never runs the password",
() => {
const { stdin } = passwordSudoScript(
'echo "ran:$1${TERMIX_PW:-none}"\n',
"touch /tmp/should-not-exist",
);
// No sudo here: swap it for a fake that drops its own stdin line.
const fake = stdin.replace(
/printf '%s\\n' "\$P" \| sudo -k -S -p '' env PATH="\$PATH" [^\n]* bash -c "\$TERMIX_S"/,
'printf "%s\\n" "$P" | { IFS= read -r _; bash -c "$TERMIX_S"; }',
);
expect(fake).not.toBe(stdin);
const out = execFileSync(
"bash",
["-c", "sudo() { return 1; }; export -f sudo; exec bash -s"],
{
input: fake,
encoding: "utf8",
},
);
expect(out.trim()).toBe("ran:none");
},
);
});
describe("unit scripts", () => {
it("quotes units and refuses bad names", () => {
expect(actionScript("a.service", "enable-now")).toContain(
"systemctl enable --now -- 'a.service'",
);
expect(() => actionScript("a;b.service", "start")).toThrow();
expect(isActiveScript(["a.service", "b.timer"])).toContain(
"-- 'a.service' 'b.timer'",
);
expect(() => listUnitsScript("bogus" as never)).toThrow();
expect(openRcActionScript("sshd", "enable")).toContain(
"rc-update add 'sshd' default",
);
expect(() => openRcActionScript("sshd", "mask")).toThrow();
});
it("stops a watched command once stdin closes", () => {
expect(watchdog("journalctl -f")).toContain("cat >/dev/null");
});
});