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

+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 1.0.21
Plugin API 1.12.0.
### Added
- `@termix-ssh/plugin-sdk/host-scripts`: Docker, Linux metrics, systemd, journal and OpenRC command builders and parsers, plus `scriptShell()` and `passwordSudoScript()` to send scripts to `bash -s` on stdin. It has no Node or ssh2 imports, so the Termix mobile app runs the same code
## 1.0.20
Plugin API 1.12.0.
+5 -2
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.20 | Plugin API 1.12.0
SDK 1.0.21 | Plugin API 1.12.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -335,7 +335,9 @@ try {
`ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
sudo commands. `@termix-ssh/plugin-sdk/host-scripts` builds Docker, metrics, systemd and journal
commands and parses their output; send a script on stdin to the command `scriptShell()` returns. Hosts from
`ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
### The desktop app
@@ -905,6 +907,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
| `@termix-ssh/plugin-sdk/host-scripts` | Docker, metrics, systemd and journal command builders and parsers, scripts sent to `bash -s` |
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.20",
"version": "1.0.21",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -81,6 +81,10 @@
"types": "./dist/host-commands.d.ts",
"default": "./dist/host-commands.js"
},
"./host-scripts": {
"types": "./dist/host-scripts/index.d.ts",
"default": "./dist/host-scripts/index.js"
},
"./ssh-certs": {
"types": "./dist/ssh-certs.d.ts",
"default": "./dist/ssh-certs.js"
+3 -8
View File
@@ -11,6 +11,9 @@
export type { Client, ClientChannel } from "ssh2";
import type { Client, ClientChannel } from "ssh2";
// Homebrew and OrbStack paths a non-interactive shell misses.
import { EXTRA_PATH_DIRS } from "./host-scripts/docker.js";
export { EXTRA_PATH_DIRS };
export interface ExecResult {
stdout: string;
@@ -114,14 +117,6 @@ export interface PlatformInfo {
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 ]` +
@@ -0,0 +1,468 @@
/**
* Docker and Podman command builders and output parsers.
*
* Container ids and names are checked with CONTAINER_ID_RE before they go on
* a command line, so they need no quoting. Anything free form is quoted.
*/
import { HostScriptError } from "./text.js";
export type ContainerRuntime = "docker" | "podman";
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
return value === "podman" ? "podman" : "docker";
}
export function getRuntimeLabel(runtime: ContainerRuntime): string {
return runtime === "podman" ? "Podman" : "Docker";
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
export function containerCommand(
runtime: ContainerRuntime | undefined,
args: string,
isWindows = false,
): string {
const cli = `${normalizeContainerRuntime(runtime)} ${args}`;
// cmd and PowerShell don't understand the POSIX env prefix.
return isWindows ? cli : `PATH="${EXTRA_PATH_DIRS}:$PATH" ${cli}`;
}
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
export function isContainerId(value: unknown): value is string {
return (
typeof value === "string" &&
value.length <= 256 &&
CONTAINER_ID_RE.test(value)
);
}
function requireContainer(id: string): string {
if (!isContainerId(id))
throw new HostScriptError("That is not a valid container id");
return id;
}
export const CONTAINER_ACTIONS = [
"start",
"stop",
"restart",
"pause",
"unpause",
] as const;
export type ContainerAction = (typeof CONTAINER_ACTIONS)[number];
const LIST_FORMAT_UNIX = `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
const LIST_FORMAT_WINDOWS = `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`;
const STATS_FORMAT_UNIX = `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
const STATS_FORMAT_WINDOWS = `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`;
export function listFormat(isWindows: boolean): string {
return isWindows ? LIST_FORMAT_WINDOWS : LIST_FORMAT_UNIX;
}
export function statsFormat(isWindows: boolean): string {
return isWindows ? STATS_FORMAT_WINDOWS : STATS_FORMAT_UNIX;
}
/** One row of `ps --format listFormat()`. */
export interface ContainerRow {
id: string;
name: string;
image: string;
status: string;
state: string;
ports: string;
created: string;
}
/** CLI arguments, without the runtime, for each container command. */
export const dockerArgs = {
version: () => "--version",
ping: () => "ps",
list: (all: boolean, isWindows = false) =>
`ps ${all ? "-a " : ""}--format ${listFormat(isWindows)}`,
inspect: (id: string) => `inspect ${requireContainer(id)}`,
stats: (id: string, isWindows = false) =>
`stats ${requireContainer(id)} --no-stream --format ${statsFormat(isWindows)}`,
action: (action: ContainerAction, id: string) => {
if (!CONTAINER_ACTIONS.includes(action))
throw new HostScriptError("Unknown action");
return `${action} ${requireContainer(id)}`;
},
remove: (id: string, force: boolean) =>
`rm ${force ? "-f " : ""}${requireContainer(id)}`,
logs: (
id: string,
options: {
tail?: number;
timestamps?: boolean;
since?: string;
until?: string;
} = {},
) => {
let args = `logs ${requireContainer(id)}`;
if (options.tail && options.tail > 0)
args += ` --tail ${Math.floor(options.tail)}`;
if (options.timestamps) args += " --timestamps";
if (options.since && DOCKER_TIMESTAMP_RE.test(options.since)) {
args += ` --since ${options.since}`;
}
if (options.until && DOCKER_TIMESTAMP_RE.test(options.until)) {
args += ` --until ${options.until}`;
}
return `${args} 2>&1`;
},
states: () => `ps -a --format ${PS_STATE_FORMAT}`,
composeProjects: () => "compose ls -a --format json",
};
/** The version from `docker --version` or `podman --version`. */
export function parseRuntimeVersion(output: string): string {
return output.match(/(?:Docker|podman) version ([^\s,]+)/i)?.[1] ?? "unknown";
}
export type RuntimeProblem =
"NOT_INSTALLED" | "DAEMON_NOT_RUNNING" | "PERMISSION_DENIED" | "DOCKER_ERROR";
/** Sorts a failed `docker ps` into a reason the UI can explain. */
export function classifyRuntimeError(message: string): RuntimeProblem {
if (
/command not found|not found: (docker|podman)|No such file or directory/i.test(
message,
)
) {
return "NOT_INSTALLED";
}
if (message.includes("Cannot connect to the Docker daemon"))
return "DAEMON_NOT_RUNNING";
if (/permission denied/i.test(message)) return "PERMISSION_DENIED";
return "DOCKER_ERROR";
}
/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */
export function parseContainerList(output: string): Record<string, unknown>[] {
return output
.split("\n")
.filter((line) => line.trim())
.flatMap((line) => {
try {
return [JSON.parse(line) as Record<string, unknown>];
} catch {
return [];
}
});
}
export interface ContainerStats {
cpu: string;
memoryUsed: string;
memoryLimit: string;
memoryPercent: string;
netInput: string;
netOutput: string;
blockRead: string;
blockWrite: string;
pids: string;
}
/** Parses one `stats --no-stream --format statsFormat()` line. Throws on bad JSON. */
export function parseContainerStats(output: string): ContainerStats {
const raw = JSON.parse(output.trim()) as Record<string, string>;
const split = (value: string | undefined) => {
const [first, second] = (value ?? "").split(" / ");
return [first?.trim() || "0B", second?.trim() || "0B"];
};
const [memoryUsed, memoryLimit] = split(raw.memory);
const [netInput, netOutput] = split(raw.netIO);
const [blockRead, blockWrite] = split(raw.blockIO);
return {
cpu: raw.cpu,
memoryUsed,
memoryLimit,
memoryPercent: raw.memoryPercent,
netInput,
netOutput,
blockRead,
blockWrite,
pids: raw.pids,
};
}
export interface ContainerState {
/** Docker's own state word: running, exited, restarting, ... */
state: string;
/** Health from the status text, when the image declares a healthcheck. */
unhealthy: boolean;
}
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
export function parseContainerStates(
output: string,
): Map<string, ContainerState> {
const states = new Map<string, ContainerState>();
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as {
name?: string;
state?: string;
status?: string;
};
if (!parsed.name) continue;
states.set(parsed.name, {
state: (parsed.state ?? "").toLowerCase(),
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
});
} catch {
// A partial line is not worth failing the whole poll over.
}
}
return states;
}
/**
* The events a pair of snapshots implies. A container missing from the
* previous snapshot counts as newly seen rather than started, so the first
* poll after a restart does not replay every running container.
*/
export function diffContainerStates(
previous: Map<string, ContainerState>,
current: Map<string, ContainerState>,
): Array<{ container: string; event: DockerEventName }> {
const events: Array<{ container: string; event: DockerEventName }> = [];
for (const [name, now] of current) {
const before = previous.get(name);
if (!before) continue;
if (before.state !== now.state) {
if (now.state === "exited")
events.push({ container: name, event: "exited" });
else if (now.state === "running")
events.push({ container: name, event: "started" });
else if (now.state === "restarting") {
events.push({ container: name, event: "restarting" });
}
}
// A container can go unhealthy while it stays up.
if (!before.unhealthy && now.unhealthy) {
events.push({ container: name, event: "unhealthy" });
}
}
return events;
}
export interface ContainerCounts {
running: number;
stopped: number;
unhealthy: number;
total: number;
}
/** Counts from `ps -a --format PS_STATE_FORMAT` output. */
export function countContainers(output: string): ContainerCounts {
const states = [...parseContainerStates(output).values()];
const running = states.filter((s) => s.state === "running").length;
return {
running,
stopped: states.length - running,
unhealthy: states.filter((s) => s.unhealthy).length,
total: states.length,
};
}
// Compose stacks
export const STACK_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
export const STACK_SERVICE_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,126}$/;
export const COMPOSE_FILES = [
"compose.yaml",
"compose.yml",
"docker-compose.yaml",
"docker-compose.yml",
];
export const STACK_ACTIONS = [
"deploy",
"redeploy",
"pull",
"start",
"stop",
"restart",
"pause",
"unpause",
"down",
"destroy",
] as const;
export type StackAction = (typeof STACK_ACTIONS)[number];
/** Actions that can target one service of a stack. */
export const STACK_SERVICE_ACTIONS: StackAction[] = [
"deploy",
"pull",
"start",
"stop",
"restart",
"pause",
"unpause",
];
export type StackState =
"running" | "partial" | "stopped" | "paused" | "not-deployed";
export interface StackSummary {
name: string;
managed: boolean;
state: StackState;
running: number;
total: number;
composeFile: string | null;
}
export interface StackService {
service: string;
name: string;
id: string;
image: string;
state: string;
status: string;
health: string;
ports: string;
}
/** `compose ls` and `compose ps --format json` print one object per line or one array. */
export function parseJsonLines(output: string): Record<string, unknown>[] {
const text = output.trim();
if (!text) return [];
if (text.startsWith("[")) {
try {
return JSON.parse(text) as Record<string, unknown>[];
} catch {
return [];
}
}
return text.split("\n").flatMap((line) => {
try {
return [JSON.parse(line) as Record<string, unknown>];
} catch {
return [];
}
});
}
const str = (value: unknown): string =>
typeof value === "string" ? value : "";
export function parseServices(output: string): StackService[] {
return parseJsonLines(output).map((row) => {
const publishers = Array.isArray(row.Publishers)
? (row.Publishers as Record<string, unknown>[])
: [];
const ports = publishers
.filter((p) => Number(p.PublishedPort) > 0)
.map((p) => `${p.PublishedPort}:${p.TargetPort}`)
.filter((value, index, all) => all.indexOf(value) === index)
.join(", ");
return {
service: str(row.Service),
name: str(row.Name),
id: str(row.ID),
image: str(row.Image),
state: str(row.State),
status: str(row.Status),
health: str(row.Health),
ports,
};
});
}
function stateOf(running: number, paused: number, total: number): StackState {
if (total === 0) return "not-deployed";
if (paused === total) return "paused";
if (running === total) return "running";
if (running > 0) return "partial";
return "stopped";
}
/** Turns the `compose ls` status text ("running(2), exited(1)") into a state. */
export function parseStackStatus(status: string): {
state: StackState;
running: number;
total: number;
} {
let running = 0;
let paused = 0;
let total = 0;
for (const part of status.split(",")) {
const match = part.trim().match(/^([a-z]+)\((\d+)\)$/i);
if (!match) continue;
const count = Number(match[2]);
total += count;
if (match[1] === "running") running += count;
if (match[1] === "paused") paused += count;
}
return { state: stateOf(running, paused, total), running, total };
}
export function stackStateFromServices(services: StackService[]) {
const total = services.length;
const running = services.filter((s) => s.state === "running").length;
const paused = services.filter((s) => s.state === "paused").length;
return { state: stateOf(running, paused, total), running, total };
}
/** Stacks from `compose ls -a --format json`, for hosts with no stacks folder. */
export function parseComposeProjects(output: string): StackSummary[] {
return parseJsonLines(output)
.flatMap((project) => {
const name = str(project.Name);
if (!STACK_NAME_RE.test(name)) return [];
return [
{
name,
managed: false,
...parseStackStatus(str(project.Status)),
composeFile: str(project.ConfigFiles).split(",")[0] || null,
},
];
})
.sort((a, b) => a.name.localeCompare(b.name));
}
/** The compose commands one action runs, in order. */
export function composeArgsFor(
action: StackAction,
service: string | undefined,
options: { removeVolumes?: boolean },
): string[] {
const target = service ? ` ${service}` : "";
switch (action) {
case "deploy":
return [`up -d --remove-orphans${target}`];
case "redeploy":
return [`pull${target}`, `up -d --remove-orphans${target}`];
case "pull":
return [`pull${target}`];
case "start":
case "stop":
case "restart":
case "pause":
case "unpause":
return [`${action}${target}`];
case "down":
return ["down --remove-orphans"];
case "destroy":
return [`down --remove-orphans${options.removeVolumes ? " -v" : ""}`];
}
}
@@ -0,0 +1,15 @@
/**
* Host scripts: command builders and output parsers for Docker, Linux
* metrics, systemd, the journal and OpenRC.
*
* Pure TypeScript with no Node or ssh2 imports, so the same code runs in a
* plugin backend and in the mobile app. Run a script by sending it on stdin
* to the command from `scriptShell()`.
*/
export * from "./text.js";
export * from "./shell.js";
export * from "./docker.js";
export * from "./metrics.js";
export * from "./systemd.js";
export * from "./journal.js";
export * from "./openrc.js";
@@ -0,0 +1,242 @@
/** journalctl scripts and parsers. */
import { isUnit } from "./systemd.js";
import { HostScriptError, shellQuote as q, utf8Decode } from "./text.js";
export const MAX_JOURNAL_LINES = 5000;
const FIELDS = "MESSAGE,PRIORITY,_SYSTEMD_UNIT,SYSLOG_IDENTIFIER,_PID,_COMM";
export interface JournalQuery {
units: string[];
priority?: number;
since?: string;
until?: string;
boot?: string;
grep?: string;
lines: number;
kernel: boolean;
}
export interface JournalEntry {
/** Unix ms. */
time: number;
priority: number;
unit: string;
ident: string;
pid: string;
message: string;
}
export interface BootView {
offset: number;
id: string;
first: string;
last: string;
}
const RELATIVE_RE = /^-\d{1,5}(s|m|min|h|d|w)$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/;
const WORDS = ["today", "yesterday", "now"];
export function isJournalTime(value: string): boolean {
return (
RELATIVE_RE.test(value) || DATE_RE.test(value) || WORDS.includes(value)
);
}
function str(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** Reads journal filters from a query string. Anything odd throws a HostScriptError. */
export function parseJournalQuery(
input: Record<string, unknown>,
): JournalQuery {
const rawUnits = Array.isArray(input.unit) ? input.unit : [input.unit];
const units = rawUnits.map(str).filter(Boolean);
for (const unit of units) {
if (!isUnit(unit))
throw new HostScriptError("That is not a valid unit name");
}
if (units.length > 20) throw new HostScriptError("Too many units");
const query: JournalQuery = {
units,
lines: 300,
kernel: input.kernel === "true" || input.kernel === true,
};
const priority = str(input.priority);
if (priority) {
const value = Number(priority);
if (!Number.isInteger(value) || value < 0 || value > 7) {
throw new HostScriptError("priority must be 0 to 7");
}
query.priority = value;
}
for (const key of ["since", "until"] as const) {
const value = str(input[key]);
if (!value) continue;
if (!isJournalTime(value))
throw new HostScriptError(`${key} is not a valid time`);
query[key] = value;
}
const boot = str(input.boot);
if (boot) {
if (!/^(-?\d{1,4}|[0-9a-f]{32})$/.test(boot))
throw new HostScriptError("boot is not valid");
query.boot = boot;
}
const grep = typeof input.grep === "string" ? input.grep : "";
if (grep) {
if (grep.length > 200 || /[\r\n\0]/.test(grep)) {
throw new HostScriptError("The search is too long");
}
query.grep = grep;
}
const lines = Number(str(input.lines) || 300);
if (Number.isFinite(lines) && lines > 0) {
query.lines = Math.min(MAX_JOURNAL_LINES, Math.round(lines));
}
return query;
}
/** journalctl arguments for a query, each one quoted. */
export function journalArgs(
query: Omit<JournalQuery, "lines"> & { lines?: number },
options: { follow?: boolean; useGrep?: boolean } = {},
): string {
const args = ["journalctl", "--no-pager", "-q", "-o", "json"];
args.push(`--output-fields=${FIELDS}`);
if (options.follow) args.push("-f");
if (query.lines) args.push("-n", String(Math.floor(query.lines)));
for (const unit of query.units) {
if (!isUnit(unit))
throw new HostScriptError("That is not a valid unit name");
args.push("-u", q(unit));
}
if (query.kernel) args.push("-k");
if (query.priority !== undefined)
args.push("-p", String(Math.floor(query.priority)));
if (query.since) args.push("--since", q(query.since));
if (query.until) args.push("--until", q(query.until));
if (query.boot) args.push("-b", q(query.boot));
if (query.grep && options.useGrep !== false) {
args.push("--case-sensitive=false", "-g", q(query.grep));
}
return args.join(" ");
}
export function journalScript(query: JournalQuery): string {
// Older journalctl builds have no -g. Then the search runs on our side instead.
const noGrep = journalArgs(
{ ...query, lines: query.grep ? MAX_JOURNAL_LINES : query.lines },
{ useGrep: false },
);
return `
command -v journalctl >/dev/null 2>&1 || { echo "SCRIPT_ERR:This host has no journal"; exit 1; }
${journalArgs(query)} 2>/tmp/termix-journal.$$
RC=$?
if [ $RC -ne 0 ] && grep -qi "pattern" /tmp/termix-journal.$$; then
rm -f /tmp/termix-journal.$$
echo "@@TERMIX_NOGREP@@"
${noGrep}
exit 0
fi
cat /tmp/termix-journal.$$ >&2; rm -f /tmp/termix-journal.$$
exit $RC
`;
}
function messageOf(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return utf8Decode(value.filter((n): n is number => typeof n === "number"));
}
return value == null ? "" : String(value);
}
/** One `journalctl -o json` line, or null when it is not an entry. */
export function parseEntry(line: string): JournalEntry | null {
const text = line.trim();
if (!text.startsWith("{")) return null;
let raw: Record<string, unknown>;
try {
raw = JSON.parse(text) as Record<string, unknown>;
} catch {
return null;
}
const micros = Number(raw.__REALTIME_TIMESTAMP);
const priority = Number(raw.PRIORITY);
return {
time: Number.isFinite(micros) ? Math.floor(micros / 1000) : 0,
priority: Number.isInteger(priority) ? priority : 6,
unit: messageOf(raw._SYSTEMD_UNIT),
ident: messageOf(raw.SYSLOG_IDENTIFIER ?? raw._COMM),
pid: messageOf(raw._PID),
message: messageOf(raw.MESSAGE),
};
}
export function parseJournal(
output: string,
query: JournalQuery,
): JournalEntry[] {
const fallback = output.includes("@@TERMIX_NOGREP@@");
const body = fallback ? output.split("@@TERMIX_NOGREP@@")[1] : output;
let entries = body
.split("\n")
.map(parseEntry)
.filter((entry): entry is JournalEntry => entry !== null);
if (fallback && query.grep) {
const needle = query.grep.toLowerCase();
entries = entries
.filter((entry) => entry.message.toLowerCase().includes(needle))
.slice(-query.lines);
}
return entries;
}
export const BOOTS_SCRIPT = `journalctl --list-boots --no-pager 2>/dev/null | tail -n 50; exit 0\n`;
export function parseBoots(output: string): BootView[] {
const boots: BootView[] = [];
for (const line of output.split("\n")) {
const match =
/^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\w{3} \S+ \S+ \S+)\s*.?\s*(\w{3} \S+ \S+ \S+)?/.exec(
line,
);
if (!match) continue;
boots.push({
offset: Number(match[1]),
id: match[2],
first: match[3],
last: match[4] ?? "",
});
}
return boots.reverse();
}
export const USAGE_SCRIPT = `journalctl --disk-usage 2>&1; exit 0\n`;
export function parseUsage(output: string): string {
return /take up (\S+)/.exec(output)?.[1] ?? "";
}
const SIZE_RE = /^\d{1,6}(K|M|G)$/;
const AGE_RE = /^\d{1,4}(d|w|month|months|year|years)$/;
export function vacuumScript(body: Record<string, unknown>): string {
const size = str(body.size);
const time = str(body.time);
if (!size && !time) throw new HostScriptError("Give a size or an age");
if (size && !SIZE_RE.test(size))
throw new HostScriptError("Size looks like 500M or 2G");
if (time && !AGE_RE.test(time))
throw new HostScriptError("Age looks like 7d or 4w");
const flags = [
size ? `--vacuum-size=${size}` : "",
time ? `--vacuum-time=${time}` : "",
]
.filter(Boolean)
.join(" ");
return `journalctl ${flags} 2>&1 || { echo "SCRIPT_ERR:journalctl vacuum failed"; exit 1; }\n`;
}
@@ -0,0 +1,433 @@
/**
* Linux metrics: one awk sample of /proc for CPU, memory, load, network and
* processes, plus df parsing for disks.
*/
// One awk reads every /proc file itself, so a sample is one exec of awk instead
// of a shell plus six tools. Output sections are split by @@; each process line
// is pid, utime+stime ticks, rss pages, starttime, state, ppid, comm.
const AWK = String.raw`BEGIN{f="/proc/stat";while((getline l<f)>0)if(l~/^cpu/)print l;close(f);print "@@";f="/proc/meminfo";while((getline l<f)>0)if(l~/^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):/)print l;close(f);print "@@";f="/proc/loadavg";getline l<f;print l;close(f);f="/proc/uptime";getline l<f;print l;close(f);print "@@";f="/proc/net/dev";while((getline l<f)>0)print l;close(f);print "@@";print pg;print "@@";for(i=1;i<ARGC;i++){f=ARGV[i];if((getline s<f)>0){a=index(s,"(");if(a&&match(s,/\)[^)]*$/)){split(substr(s,RSTART+2),x," ");print substr(s,1,a-2)" "(x[12]+x[13])" "x[22]"\t"x[20]"\t"x[1]" "x[2]" "substr(s,a+1,RSTART-a-1)}}close(f)}}`;
/** The live sample command. The page size never changes on a host, so pass it back once known. */
export const liveCmd = (pageBytes?: number) =>
"awk -v pg=" +
(pageBytes ?? '"$(getconf PAGESIZE)"') +
" '" +
AWK +
"' /proc/[0-9]*/stat";
export interface LiveSample {
/** The admin minimum between samples; the client never polls faster. */
minIntervalS?: number;
cpu: number | null;
cpuMix?: { user: number; system: number; iowait: number; steal: number };
cores: Array<number | null>;
coreIds: string[];
memTotalKb: number;
memCachedKb: number;
memFreeKb: number;
memUsedKb: number;
swapTotalKb: number;
swapUsedKb: number;
load: [number, number, number];
net: Array<{ name: string; rxBps: number | null; txBps: number | null }>;
count: number;
running: number;
procs: Array<{
pid: number;
name: string;
cpu: number;
memKb: number;
state: string;
ppid: number;
}>;
/** Seconds since boot. */
uptime?: number;
/** Total bytes received and sent on every interface but lo, since boot. */
netTotals?: { rx: number; tx: number };
}
/** What the next call needs from this one. */
export interface LivePrev {
at: number;
uptime: number;
pageBytes: number;
cpu: Map<string, { total: number; idle: number }>;
/** Aggregate user nice system idle iowait irq softirq steal ticks, for the breakdown. */
cpuRaw?: number[];
/** Keyed by pid:starttime so a reused pid never inherits another process's ticks. */
ticks: Map<string, number>;
net: Map<string, { rx: number; tx: number }>;
}
/** Total and idle ticks from a /proc/stat `cpu` line. */
export function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== "cpu") return undefined;
const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
const TOP_N = 60;
/** Parses `liveCmd()` output. Rates need the `next` of the previous call. */
export function parseLive(
output: string,
prev: LivePrev | undefined,
at = Date.now(),
): { sample: LiveSample; next: LivePrev } {
const [cpuS = "", memS = "", loadS = "", netS = "", pageS = "", procS = ""] =
output.split("@@\n");
// Only the first 8 fields: guest and guest_nice are already inside user and nice.
const cpu = new Map<string, { total: number; idle: number }>();
let cpuRaw: number[] | undefined;
for (const l of cpuS.split("\n")) {
const name = l.split(/\s+/)[0];
if (!name.startsWith("cpu")) continue;
if (name === "cpu") cpuRaw = l.split(/\s+/).slice(1, 9).map(Number);
const c = parseCpuLine(
l
.replace(/^cpu\d*/, "cpu")
.split(/\s+/)
.slice(0, 9)
.join(" "),
);
if (c) cpu.set(name, c);
}
// Matched by name, so an offline core does not shift the ones after it.
const pct = (name: string) => {
const p = prev?.cpu.get(name);
const c = cpu.get(name);
if (!p || !c || c.total <= p.total) return null;
const used = c.total - p.total - (c.idle - p.idle);
return Math.max(0, Math.min(100, (used / (c.total - p.total)) * 100));
};
// Where the busy time went since the last sample, as percent of all ticks.
let cpuMix: LiveSample["cpuMix"];
if (prev?.cpuRaw && cpuRaw && cpuRaw.length === 8) {
const d = cpuRaw.map((v, i) => v - (prev.cpuRaw?.[i] ?? 0));
const total = d.reduce((a, b) => a + b, 0);
if (total > 0 && d.every((v) => v >= 0)) {
const p = (n: number) => (n / total) * 100;
cpuMix = {
user: p(d[0] + d[1]),
system: p(d[2] + d[5] + d[6]),
iowait: p(d[4]),
steal: p(d[7]),
};
}
}
const mem: Record<string, number> = {};
for (const l of memS.split("\n")) {
const m = l.match(/^(\w+):\s+(\d+)/);
if (m) mem[m[1]] = Number(m[2]);
}
const [la = "", up = ""] = loadS.trim().split("\n");
const laParts = la.split(/\s+/).map(Number);
const uptime = Number(up.split(/\s+/)[0]) || 0;
const nets = new Map<string, { rx: number; tx: number }>();
for (const l of netS.split("\n")) {
const [name, rest] = l.split(":");
if (!rest || name.trim() === "lo") continue;
const f = rest.trim().split(/\s+/);
nets.set(name.trim(), { rx: Number(f[0]) || 0, tx: Number(f[8]) || 0 });
}
// A reboot makes uptime go backwards, so rates start again.
const dt = prev && uptime > prev.uptime ? uptime - prev.uptime : 0;
const rate = (now: number, before: number | undefined) =>
dt > 0 && before !== undefined && now >= before
? (now - before) / dt
: null;
const pageBytes = Number(pageS.trim()) || prev?.pageBytes || 4096;
const pageKb = pageBytes / 1024;
const aggDelta =
prev && cpu.has("cpu") && prev.cpu.has("cpu")
? cpu.get("cpu")!.total - prev.cpu.get("cpu")!.total
: 0;
const cores = [...cpu.keys()].filter((k) => k !== "cpu");
const ncpu = Math.max(1, cores.length);
const ticks = new Map<string, number>();
const procs: LiveSample["procs"] = [];
for (const l of procS.split("\n")) {
const [pid, t, rss, start, state, ppid, ...rest] = l.split("\t");
const name = rest.join("\t");
if (!name) continue;
const key = pid + ":" + start;
const tk = Number(t) || 0;
ticks.set(key, tk);
const before = prev?.ticks.get(key);
procs.push({
pid: Number(pid),
name,
cpu:
before !== undefined && aggDelta > 0 && tk >= before
? ((tk - before) / aggDelta) * ncpu * 100
: 0,
memKb: (Number(rss) || 0) * pageKb,
state: state || "?",
ppid: Number(ppid) || 0,
});
}
procs.sort((a, b) => b.cpu - a.cpu || b.memKb - a.memKb);
// Awk exits 0 when /proc is missing (macOS, restricted containers); that is not a sample.
if (!cpu.has("cpu") || !mem.MemTotal || !uptime) {
throw new Error("live sample unavailable: /proc data missing");
}
const memTotalKb = mem.MemTotal;
const swapTotalKb = mem.SwapTotal ?? 0;
let rxTotal = 0;
let txTotal = 0;
for (const c of nets.values()) {
rxTotal += c.rx;
txTotal += c.tx;
}
return {
sample: {
cpu: pct("cpu"),
cores: cores.map(pct),
coreIds: cores.map((k) => k.slice(3)),
cpuMix,
memTotalKb,
memCachedKb: (mem.Buffers ?? 0) + (mem.Cached ?? 0),
memFreeKb: mem.MemFree ?? 0,
memUsedKb: memTotalKb - (mem.MemAvailable ?? 0),
swapTotalKb,
swapUsedKb: swapTotalKb - (mem.SwapFree ?? 0),
load: [laParts[0] || 0, laParts[1] || 0, laParts[2] || 0],
net: [...nets].map(([name, c]) => ({
name,
rxBps: rate(c.rx, prev?.net.get(name)?.rx),
txBps: rate(c.tx, prev?.net.get(name)?.tx),
})),
count: procs.length,
running: procs.filter((p) => p.state === "R").length,
procs: procs.slice(0, TOP_N),
uptime,
netTotals: { rx: rxTotal, tx: txTotal },
},
next: { at, uptime, pageBytes, cpu, cpuRaw, ticks, net: nets },
};
}
// Disks
const DARWIN_DATA_MOUNT = "/System/Volumes/Data";
const PSEUDO_FS_RE =
/^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/;
/** GNU df sizes in KiB, without the header. */
export const DF_BYTES_COMMAND = "df -TkP | tail -n +2";
/** GNU df sizes as short human text, without the header. */
export const DF_HUMAN_COMMAND = "df -hT -P | tail -n +2";
export interface DfRow {
filesystem: string;
type: string;
mount: string;
parts: string[];
}
export interface DiskFilesystem {
filesystem: string;
type: string;
mount: string;
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
usedBytes: number | null;
totalBytes: number | null;
availableBytes: number | null;
label?: string;
}
export interface MonitoredMount {
path: string;
label?: string;
}
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
/**
* Parses `df -T -P` style output: Filesystem, Type, then the size columns,
* with Mounted-on last. Pseudo filesystems are left out.
*/
export function parseDfLines(output: string): DfRow[] {
return output
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split(/\s+/);
return {
filesystem: parts[0] || "",
type: parts[1] || "",
mount: parts[6] || "",
parts,
};
})
.filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
}
/** The most used real filesystem in a `df -TkP` row set. */
export function findWorstMountIndex(bytesRows: DfRow[]): {
index: number;
usedBytes: number;
totalBytes: number;
} {
let worstIndex = -1;
let worstUsedBytes = -1;
let worstTotalBytes = 0;
bytesRows.forEach((row, index) => {
const totalBytes = Number(row.parts[2]);
const usedBytes = Number(row.parts[3]);
if (
!Number.isFinite(totalBytes) ||
!Number.isFinite(usedBytes) ||
totalBytes <= 0
) {
return;
}
const usedRatio = usedBytes / totalBytes;
const worstRatio =
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
if (usedRatio > worstRatio) {
worstIndex = index;
worstUsedBytes = usedBytes;
worstTotalBytes = totalBytes;
}
});
return {
index: worstIndex,
usedBytes: worstUsedBytes,
totalBytes: worstTotalBytes,
};
}
/**
* Merges `df -TkP` size rows (parts[2..4] in `blockSizeBytes` units) and
* `df -hT -P` human rows into one list. Human rows only supply the display
* text, matched by mount point so a mismatched row count can't shift them.
*/
export function buildFilesystemList(
bytesRows: DfRow[],
humanRows: DfRow[],
blockSizeBytes = 1,
): DiskFilesystem[] {
const aligned = humanRows.length === bytesRows.length;
return bytesRows
.map((row, index) => {
const totalBytes = Number(row.parts[2]) * blockSizeBytes;
const usedBytes = Number(row.parts[3]) * blockSizeBytes;
const availableBytes = Number(row.parts[4]) * blockSizeBytes;
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
const humanRow = aligned
? humanRows[index]
: humanRows.find((h) => h.mount === row.mount);
const percent = Number.isFinite(usedBytes)
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
: null;
return {
filesystem: row.filesystem,
type: row.type,
mount: row.mount,
percent: toFixedNum(percent, 0),
usedHuman: humanRow?.parts[3] || null,
totalHuman: humanRow?.parts[2] || null,
availableHuman: humanRow?.parts[4] || null,
usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
totalBytes,
availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
};
})
.filter((fs): fs is DiskFilesystem => fs !== null);
}
/**
* The headline disk is the root filesystem. On macOS "/" is the small sealed
* system volume, so the data volume wins there. With no root mount the most
* used one is shown.
*/
export function selectPrimaryFilesystem(
filesystems: DiskFilesystem[],
): DiskFilesystem | null {
if (filesystems.length === 0) return null;
const root =
filesystems.find((fs) => fs.mount === DARWIN_DATA_MOUNT) ??
filesystems.find((fs) => fs.mount === "/");
if (root) return root;
let best = filesystems[0];
for (const fs of filesystems) {
const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
if (ratio > bestRatio) best = fs;
}
return best;
}
/**
* Drops mounts by exact path ("/mnt/nas") or by filesystem type substring
* ("nfs" matches nfs and nfs4), so network shares can be hidden.
*/
export function filterExcludedFilesystems(
filesystems: DiskFilesystem[],
excludedMounts?: string[] | null,
): DiskFilesystem[] {
if (!excludedMounts || excludedMounts.length === 0) return filesystems;
const normalized = excludedMounts
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
if (normalized.length === 0) return filesystems;
return filesystems.filter((fs) => {
const mount = fs.mount.toLowerCase();
const type = fs.type.toLowerCase();
return !normalized.some(
(entry) => mount === entry || (type && type.includes(entry)),
);
});
}
export function mergeMonitoredFilesystems(
filesystems: DiskFilesystem[],
monitored: MonitoredMount[],
customFilesystems: Array<DiskFilesystem | null>,
): DiskFilesystem[] {
const result = [...filesystems];
monitored.forEach((entry, index) => {
const path = entry.path.trim();
const custom = customFilesystems[index];
if (!path || !custom) return;
const existing = result.find((fs) => fs.mount === path);
if (existing) {
existing.label = entry.label?.trim() || undefined;
return;
}
result.push({
...custom,
mount: path,
label: entry.label?.trim() || undefined,
});
});
return result;
}
/** Host name, OS, kernel and arch in one exec, as key=value lines. */
export const SYSTEM_INFO_COMMAND = `echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"; echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"; echo "kernel=$(uname -r 2>/dev/null)"; echo "arch=$(uname -m 2>/dev/null)"`;
@@ -0,0 +1,94 @@
/** OpenRC scripts, with states mapped onto the words systemd uses. */
import { isOpenRcName, type UnitAction, type UnitSummary } from "./systemd.js";
import { HostScriptError, shellQuote as q } from "./text.js";
export const OPENRC_LIST_SCRIPT = `
rc-status --servicelist 2>/dev/null
echo "@@TERMIX_RUNLEVELS@@"
rc-update show -v 2>/dev/null
exit 0
`;
function activeOf(state: string): { active: string; sub: string } {
switch (state) {
case "started":
return { active: "active", sub: "running" };
case "crashed":
return { active: "failed", sub: "crashed" };
case "starting":
return { active: "activating", sub: "starting" };
case "stopping":
return { active: "deactivating", sub: "stopping" };
case "inactive":
case "hotplugged":
return { active: "inactive", sub: state };
default:
return { active: "inactive", sub: state || "stopped" };
}
}
export function parseOpenRcList(output: string): UnitSummary[] {
const [list = "", levels = ""] = output.split("@@TERMIX_RUNLEVELS@@");
const units = new Map<string, UnitSummary>();
for (const line of list.split("\n")) {
const match = /^\s*(\S+)\s+\[\s*([a-z ]+?)\s*\]/.exec(line);
if (!match) continue;
const { active, sub } = activeOf(match[2]);
units.set(match[1], {
name: match[1],
type: "service",
load: "loaded",
active,
sub,
description: "",
fileState: "disabled",
preset: "",
});
}
for (const line of levels.split("\n")) {
const match = /^\s*(\S+)\s*\|\s*(.*)$/.exec(line);
if (!match) continue;
const runlevels = match[2].trim();
const known = units.get(match[1]) ?? {
name: match[1],
type: "service",
load: "loaded",
active: "inactive",
sub: "stopped",
description: "",
fileState: "disabled",
preset: "",
};
known.fileState = runlevels ? "enabled" : "disabled";
known.description = runlevels ? `Runlevels: ${runlevels}` : "";
units.set(match[1], known);
}
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function requireName(name: string): string {
if (!isOpenRcName(name))
throw new HostScriptError("That is not a valid service name");
return name;
}
export function openRcActionScript(name: string, action: UnitAction): string {
const s = q(requireName(name));
switch (action) {
case "start":
case "stop":
case "restart":
return `rc-service ${s} ${action} 2>&1 || { echo "SCRIPT_ERR:rc-service ${action} failed"; exit 1; }\n`;
case "enable":
return `rc-update add ${s} default 2>&1 || { echo "SCRIPT_ERR:rc-update add failed"; exit 1; }\n`;
case "disable":
return `rc-update del ${s} 2>&1 || { echo "SCRIPT_ERR:rc-update del failed"; exit 1; }\n`;
default:
throw new HostScriptError("OpenRC can't do that", 400);
}
}
export function openRcStatusScript(name: string): string {
const s = q(requireName(name));
return `rc-service ${s} status 2>&1; rc-update show -v 2>/dev/null | grep -E '^ *'${s}' *\\|'; exit 0\n`;
}
@@ -0,0 +1,117 @@
/**
* How a script reaches the host: the command line runs a shell that reads
* the script from stdin (`bash -s`), so names and values in a script are
* never parsed out of argv.
*/
import { shellQuote, toBase64 } from "./text.js";
// systemctl and friends often live in sbin, which a normal login skips.
// TZ=UTC so tools print every time in UTC, which parses the same anywhere.
const ENV = "SYSTEMD_COLORS=0 SYSTEMD_PAGER=cat LC_ALL=C TZ=UTC";
/** Puts sbin on PATH and sets a plain, UTC environment. */
export const SCRIPT_PRELUDE = `export PATH="$PATH:/usr/sbin:/sbin:/usr/local/sbin"; export ${ENV}; `;
const SUDO = `sudo -n env PATH="$PATH" ${ENV}`;
/**
* - `plain`: the login user.
* - `auto`: root when it can be had without a password, else the login user.
* - `root`: root or passwordless sudo, fails on anything else.
* - `root-only`: root logins only, for hosts where sudo is turned off.
*/
export type ScriptShellMode = "plain" | "auto" | "root" | "root-only";
const ERR = "SCRIPT_ERR:";
/** The command line that runs a script sent on stdin. */
export function scriptShell(mode: ScriptShellMode): string {
switch (mode) {
case "plain":
return `${SCRIPT_PRELUDE}exec bash -s`;
case "auto":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -s; else exec bash -s; fi`;
case "root":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else exec ${SUDO} bash -s; fi`;
case "root-only":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else echo "${ERR}This needs root, or turn on Use sudo for this host"; exit 1; fi`;
}
}
/**
* Runs a script as root with a sudo password. The command is `plain`; all of
* the returned stdin goes to the host. The password is read by the first
* line into a shell variable and piped to `sudo -S`, so it is never in argv,
* never in the environment and never run as a command. Root logins and
* passwordless sudo skip the password. The script itself travels as base64.
*/
export function passwordSudoScript(
script: string,
password: string,
): { command: string; stdin: string } {
if (/[\r\n\0]/.test(password)) {
throw new Error("A sudo password can't contain a line break");
}
const run = `bash -c "$TERMIX_S"`;
const stdin = `IFS= read -r TERMIX_PW
${password}
TERMIX_S=$(printf '%s' ${shellQuote(toBase64(script))} | base64 -d)
if [ "$(id -u)" = "0" ]; then unset TERMIX_PW; exec ${run}
elif sudo -n true 2>/dev/null; then unset TERMIX_PW; exec ${SUDO} ${run}
else P=$TERMIX_PW; unset TERMIX_PW; printf '%s\\n' "$P" | sudo -k -S -p '' env PATH="$PATH" ${ENV} ${run}; exit $?
fi
`;
return { command: scriptShell("plain"), stdin };
}
/** sudo asked for a password or a terminal it did not get. */
export const SUDO_REFUSED_RE =
/password is required|a terminal is required|no tty present/i;
/** sudo got a password and it was wrong. */
export const SUDO_WRONG_RE =
/incorrect password|sorry, try again|no password was provided/i;
/** The user may not use sudo at all. */
export const NOT_SUDOER_RE =
/is not in the sudoers file|not allowed to run sudo/i;
/**
* Scripts print `SCRIPT_ERR:` lines (or the older `SERVICES_ERR:`) before a
* non zero exit to explain a failure.
*/
export function scriptError(stdout: string): string {
return stdout
.split("\n")
.map((line) => {
if (line.startsWith(ERR)) return line.slice(ERR.length);
if (line.startsWith("SERVICES_ERR:")) return line.slice(13);
return null;
})
.filter((line): line is string => line !== null)
.join("\n")
.trim();
}
/** A line a script prints to explain why it stops. */
export function errLine(message: string): string {
return `echo ${shellQuote(ERR + message)}`;
}
/**
* Runs a long command in the background and stops it once stdin closes,
* which happens when the SSH channel ends. A pty can't be trusted for this:
* sudo keeps its own and the command would outlive the viewer.
*/
export function watchdog(command: string): string {
return `${command} &
P=$!
trap 'kill $P 2>/dev/null' EXIT
cat >/dev/null
`;
}
/** The wrapper a streamed command runs in, as one shell command line. */
export function streamCommand(script: string, sudo: boolean): string {
const quoted = shellQuote(`${SCRIPT_PRELUDE}${script}`);
return sudo
? `if [ "$(id -u)" = "0" ]; then exec bash -c ${quoted}; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -c ${quoted}; else exec bash -c ${quoted}; fi`
: `exec bash -c ${quoted}`;
}
@@ -0,0 +1,389 @@
/**
* systemd unit scripts and parsers, plus init system detection.
* Every script is meant for `scriptShell()`, sent on stdin.
*/
import { HostScriptError, parseKeys, shellQuote as q } from "./text.js";
export const UNIT_TYPES = [
"service",
"socket",
"timer",
"target",
"mount",
"automount",
"path",
"swap",
"slice",
"scope",
"device",
] as const;
export type UnitType = (typeof UNIT_TYPES)[number];
// Backslash is allowed for escaped names like dev-disk-by\x2duuid.
const UNIT_RE = new RegExp(
`^[A-Za-z0-9_][A-Za-z0-9:_.@\\\\-]{0,250}\\.(${UNIT_TYPES.join("|")})$`,
);
const OPENRC_RE = /^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,127}$/;
export function isUnit(name: unknown): name is string {
return typeof name === "string" && UNIT_RE.test(name);
}
export function unitType(name: string): UnitType {
return name.slice(name.lastIndexOf(".") + 1) as UnitType;
}
/** Units a file may be written for. Templates and device style names are out. */
export function isWritableUnitName(name: string): boolean {
return (
isUnit(name) &&
!name.includes("\\") &&
["service", "socket", "timer", "target", "path", "mount"].includes(
unitType(name),
)
);
}
export function isOpenRcName(name: unknown): name is string {
return typeof name === "string" && OPENRC_RE.test(name);
}
export function requireUnit(name: unknown): string {
if (!isUnit(name)) throw new HostScriptError("That is not a valid unit name");
return name;
}
export type InitSystem = "systemd" | "openrc" | "none";
/** A unit name or, on OpenRC, a service name. */
export function requireService(name: unknown, init: string): string {
if (init === "openrc") {
if (!isOpenRcName(name))
throw new HostScriptError("That is not a valid service name");
return name;
}
return requireUnit(name);
}
export interface UnitSummary {
name: string;
type: string;
load: string;
active: string;
sub: string;
description: string;
/** enabled, disabled, static, masked and so on. Empty when unknown. */
fileState: string;
preset: string;
}
export interface UnitDetail {
name: string;
props: Record<string, string>;
status: string;
}
export interface TimerView {
name: string;
description: string;
active: string;
fileState: string;
/** Unix ms, or null when not set. */
next: number | null;
last: number | null;
activates: string;
schedule: string[];
}
export const UNIT_ACTIONS = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
"enable-now",
"disable-now",
"mask",
"unmask",
"reset-failed",
"revert",
] as const;
export type UnitAction = (typeof UNIT_ACTIONS)[number];
/** Actions OpenRC can do. */
export const OPENRC_ACTIONS: readonly UnitAction[] = [
"start",
"stop",
"restart",
"enable",
"disable",
];
export const DETECT_SCRIPT = `
INIT=none
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then INIT=systemd
elif command -v rc-service >/dev/null 2>&1; then INIT=openrc; fi
echo "init=$INIT"
if [ "$INIT" = systemd ]; then echo "version=$(systemctl --version 2>/dev/null | head -n1 | awk '{print $2}')"; fi
if [ "$INIT" = openrc ]; then echo "version=$(openrc --version 2>/dev/null | head -n1 | awk '{print $NF}')"; fi
echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"
echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"
echo "uid=$(id -u)"
if command -v journalctl >/dev/null 2>&1; then echo "journal=1"; fi
if [ "$INIT" = systemd ]; then echo "failed=$(systemctl list-units --state=failed --plain --no-legend --no-pager 2>/dev/null | wc -l)"; fi
if [ "$INIT" = openrc ]; then echo "failed=$(rc-status --crashed 2>/dev/null | wc -l)"; fi
exit 0
`;
export interface Detected {
init: InitSystem;
version: string;
os: string;
hostname: string;
root: boolean;
journal: boolean;
failed: number;
}
export function parseDetect(output: string): Detected {
const keys = parseKeys(output);
const init =
keys.init === "systemd" || keys.init === "openrc" ? keys.init : "none";
return {
init,
version: keys.version ?? "",
os: keys.os ?? "",
hostname: keys.hostname ?? "",
root: keys.uid === "0",
journal: keys.journal === "1",
failed: Number(keys.failed) || 0,
};
}
/** Types the list can show. */
export const LIST_TYPES = [
"service",
"socket",
"timer",
"target",
"path",
"mount",
] as const;
export type ListType = (typeof LIST_TYPES)[number] | "all";
export function listUnitsScript(type: ListType): string {
const types = type === "all" ? LIST_TYPES.join(",") : type;
if (type !== "all" && !LIST_TYPES.includes(type))
throw new HostScriptError("Unknown unit type");
return `
systemctl list-units --all --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
echo "@@TERMIX_FILES@@"
systemctl list-unit-files --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
exit 0
`;
}
/** Merges list-units and list-unit-files. Template units are left out. */
export function parseUnitList(output: string): UnitSummary[] {
const [loaded = "", files = ""] = output.split("@@TERMIX_FILES@@");
const units = new Map<string, UnitSummary>();
for (const raw of loaded.split("\n")) {
const line = raw.replace(/^[●*\s]+/, "").trimEnd();
if (!line) continue;
const match = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line);
if (!match) continue;
const [, name, load, active, sub, description] = match;
if (!name.includes(".") || name.includes("@.")) continue;
units.set(name, {
name,
type: unitType(name),
load,
active,
sub,
description: description.trim(),
fileState: "",
preset: "",
});
}
for (const raw of files.split("\n")) {
const line = raw.trim();
if (!line) continue;
const [name, state = "", preset = ""] = line.split(/\s+/);
if (!name || !name.includes(".") || name.includes("@.")) continue;
const known = units.get(name);
const cleanPreset = preset === "-" ? "" : preset;
if (known) {
known.fileState = state;
known.preset = cleanPreset;
} else {
units.set(name, {
name,
type: unitType(name),
load: state === "masked" ? "masked" : "unloaded",
active: "inactive",
sub: "dead",
description: "",
fileState: state,
preset: cleanPreset,
});
}
}
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export const DETAIL_PROPS = [
"Id",
"Description",
"LoadState",
"ActiveState",
"SubState",
"UnitFileState",
"UnitFilePreset",
"Result",
"MainPID",
"ExecMainStartTimestamp",
"ActiveEnterTimestamp",
"InactiveEnterTimestamp",
"MemoryCurrent",
"MemoryPeak",
"CPUUsageNSec",
"TasksCurrent",
"NRestarts",
"FragmentPath",
"DropInPaths",
"Requires",
"Wants",
"After",
"WantedBy",
"TriggeredBy",
"Triggers",
"User",
"Group",
"Restart",
"Type",
"ExecStart",
"CanStart",
"CanStop",
"CanReload",
] as const;
export function detailScript(unit: string): string {
const props = DETAIL_PROPS.map((p) => `-p ${p}`).join(" ");
return `
systemctl show --no-pager ${props} -- ${q(requireUnit(unit))}
echo "@@TERMIX_STATUS@@"
systemctl status --no-pager --full -n 0 -- ${q(unit)} 2>&1
exit 0
`;
}
export function parseDetail(unit: string, output: string): UnitDetail {
const [props = "", status = ""] = output.split("@@TERMIX_STATUS@@");
return { name: unit, props: parseKeys(props), status: status.trim() };
}
const VERBS: Record<UnitAction, string> = {
start: "start",
stop: "stop",
restart: "restart",
reload: "reload",
enable: "enable",
disable: "disable",
"enable-now": "enable --now",
"disable-now": "disable --now",
mask: "mask",
unmask: "unmask",
"reset-failed": "reset-failed",
revert: "revert",
};
/** The systemctl call for an action. Prints `state=` after it. */
export function actionScript(unit: string, action: UnitAction): string {
const verb = VERBS[action];
if (!verb) throw new HostScriptError("Unknown action");
const u = q(requireUnit(unit));
return `
systemctl ${verb} -- ${u} 2>&1 || { echo "SCRIPT_ERR:systemctl ${verb} failed"; exit 1; }
echo "state=$(systemctl is-active -- ${u} 2>/dev/null)"
`;
}
/** Times systemctl prints under TZ=UTC, like "Thu 2026-10-22 02:03:51 UTC". */
export function parseSystemdTime(value: string | undefined): number | null {
if (!value) return null;
const match = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(value);
if (!match) return null;
const [, y, mo, d, h, mi, s] = match.map(Number);
return Date.UTC(y, mo - 1, d, h, mi, s);
}
export const TIMERS_SCRIPT = `
UNITS=$( { systemctl list-units --all --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; systemctl list-unit-files --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; } | grep '\\.timer$' | grep -v '@\\.timer$' | sort -u)
[ -n "$UNITS" ] || exit 0
systemctl show --no-pager -p Id -p Description -p ActiveState -p UnitFileState -p NextElapseUSecRealtime -p LastTriggerUSec -p Triggers -p TimersCalendar -p TimersMonotonic -- $UNITS
exit 0
`;
/** Reads systemctl show output for many units, split by blank lines. */
export function parseShowBlocks(output: string): Array<Record<string, string>> {
const blocks: Array<Record<string, string>> = [];
for (const block of output.split(/\n\s*\n/)) {
const lines = block.split("\n").filter((line) => line.includes("="));
if (!lines.length) continue;
// A unit can have several TimersCalendar lines; keep all of them.
const keys: Record<string, string> = {};
for (const line of lines) {
const eq = line.indexOf("=");
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
keys[key] = key in keys ? `${keys[key]}\n${value}` : value;
}
blocks.push(keys);
}
return blocks;
}
function schedules(value: string | undefined): string[] {
if (!value) return [];
const out: string[] = [];
for (const line of value.split("\n")) {
const match = /\{\s*(\w+)=(.+?)\s*;/.exec(line);
if (match) out.push(`${match[1]}=${match[2]}`);
}
return out;
}
export function parseTimers(output: string): TimerView[] {
return parseShowBlocks(output)
.filter((keys) => keys.Id?.endsWith(".timer"))
.map((keys) => ({
name: keys.Id,
description: keys.Description ?? "",
active: keys.ActiveState ?? "",
fileState: keys.UnitFileState ?? "",
next: parseSystemdTime(keys.NextElapseUSecRealtime),
last: parseSystemdTime(keys.LastTriggerUSec),
activates: (keys.Triggers ?? "").split(/\s+/)[0] ?? "",
schedule: [
...schedules(keys.TimersCalendar),
...schedules(keys.TimersMonotonic),
],
}))
.sort((a, b) => (a.next ?? Infinity) - (b.next ?? Infinity));
}
/** One line per unit from systemctl is-active, in the order asked. */
export function isActiveScript(units: string[]): string {
return `systemctl is-active -- ${units.map((u) => q(requireUnit(u))).join(" ")} 2>/dev/null; exit 0\n`;
}
export function parseIsActive(
units: string[],
output: string,
): Map<string, string> {
const lines = output.split("\n").map((line) => line.trim());
const out = new Map<string, string>();
units.forEach((unit, i) => out.set(unit, lines[i] || "unknown"));
return out;
}
@@ -0,0 +1,118 @@
/**
* Quoting, encoding and the error type the host scripts share.
*
* No Node APIs here: this entry also runs in the mobile app.
*/
/** A bad input to a host script builder. `status` is the HTTP status to answer with. */
export class HostScriptError extends Error {
constructor(
message: string,
readonly status = 400,
readonly extra: Record<string, unknown> = {},
) {
super(message);
this.name = "HostScriptError";
}
}
/** Wraps a value in single quotes for a POSIX shell. */
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
// Written by hand because some JS engines (Hermes) have no TextDecoder.
/** UTF-8 bytes of a string. */
export function utf8Encode(text: string): Uint8Array {
const out: number[] = [];
for (const char of text) {
const cp = char.codePointAt(0) ?? 0xfffd;
if (cp < 0x80) out.push(cp);
else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 63));
else if (cp < 0x10000) {
out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63));
} else {
out.push(
0xf0 | (cp >> 18),
0x80 | ((cp >> 12) & 63),
0x80 | ((cp >> 6) & 63),
0x80 | (cp & 63),
);
}
}
return Uint8Array.from(out);
}
/** A string from UTF-8 bytes. Bad sequences become U+FFFD. */
export function utf8Decode(bytes: ArrayLike<number>): string {
let out = "";
let i = 0;
while (i < bytes.length) {
const b = bytes[i];
let need = 0;
let cp = 0;
if (b < 0x80) cp = b;
else if (b >= 0xc2 && b < 0xe0) [need, cp] = [1, b & 31];
else if (b >= 0xe0 && b < 0xf0) [need, cp] = [2, b & 15];
else if (b >= 0xf0 && b < 0xf5) [need, cp] = [3, b & 7];
else {
out += "\ufffd";
i++;
continue;
}
let j = 1;
for (; j <= need; j++) {
const next = bytes[i + j];
if (next === undefined || (next & 0xc0) !== 0x80) break;
cp = (cp << 6) | (next & 63);
}
const tooSmall = [0, 0x80, 0x800, 0x10000][need];
if (
j <= need ||
cp < tooSmall ||
cp > 0x10ffff ||
(cp >= 0xd800 && cp < 0xe000)
) {
out += "\ufffd";
i += Math.max(1, j);
continue;
}
out += String.fromCodePoint(cp);
i += need + 1;
}
return out;
}
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/** Base64 of the UTF-8 bytes of a string, for `base64 -d` on the host. */
export function toBase64(text: string): string {
const bytes = utf8Encode(text);
let out = "";
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i];
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
const n = (a << 16) | (b << 8) | c;
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : "=";
out += i + 2 < bytes.length ? B64[n & 63] : "=";
}
return out;
}
/** Reads key=value lines. Only the first = splits a line. */
export function parseKeys(output: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of output.split("\n")) {
const eq = line.indexOf("=");
if (eq > 0) out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
return out;
}
/** Trims output and keeps it short enough to show. */
export function scrub(text: string, max = 600): string {
return text.trim().slice(0, max);
}