// systemctl and journalctl, asked in one scope or the other (novox/hq ADR 0177). // // The system manager is the machine's. The user manager is the operator account's own: reached as // `systemctl --user --machine=@` when this process is not that account (the node tools // runtime runs as the node's account, root when the host started it), and as plain `--user` when // it is. It answers only while the account's manager runs — a login, or lingering enabled. import { execFile } from "node:child_process"; import { userInfo } from "node:os"; export type Scope = "system" | "user"; export interface Unit { unit: string; load: string; active: string; sub: string; description: string; } function run(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string; status: number }> { return new Promise((resolve) => { execFile(cmd, args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => { const status = err && typeof (err as { code?: unknown }).code === "number" ? ((err as { code: number }).code) : err ? 1 : 0; resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? "") + (err && !(err as { code?: unknown }).code ? err.message : ""), status }); }); }); } export class ServiceManager { constructor(private readonly account: string) {} static fromEnv(env: NodeJS.ProcessEnv): ServiceManager { return new ServiceManager(env.MESH_OPERATOR_ACCOUNT?.trim() || userInfo().username); } /** The leading arguments that pick a manager. */ scopeArgs(scope: Scope): string[] { if (scope !== "user") return []; return userInfo().username === this.account ? ["--user"] : ["--user", `--machine=${this.account}@`]; } async systemctl(scope: Scope, ...args: string[]): Promise<{ stdout: string; stderr: string; status: number }> { return run("systemctl", [...this.scopeArgs(scope), ...args]); } async units(scope: Scope, pattern?: string): Promise { const args = ["list-units", "--all", "--no-legend", "--plain", "--no-pager"]; if (pattern) args.push(pattern); const { stdout } = await this.systemctl(scope, ...args); return stdout .split("\n") .map((l) => l.trim()) .filter(Boolean) .map((l) => { const [unit, load, active, sub, ...rest] = l.split(/\s+/); return { unit, load, active, sub, description: rest.join(" ") }; }); } async status(scope: Scope, unit: string): Promise> { const props = ["LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID", "ExecMainStatus", "Description", "FragmentPath"]; const { stdout } = await this.systemctl(scope, "show", unit, ...props.map((p) => `--property=${p}`)); const out: Record = { unit, scope }; for (const line of stdout.split("\n")) { const i = line.indexOf("="); if (i > 0) out[line.slice(0, i)] = line.slice(i + 1); } return out; } async act(scope: Scope, verb: "start" | "stop" | "restart" | "enable" | "disable", unit: string): Promise> { const { stderr, status } = await this.systemctl(scope, verb, unit); const after = await this.status(scope, unit); return { unit, scope, verb, ok: status === 0, stderr: stderr.trim(), active: after.ActiveState, boot: after.UnitFileState, note: "a unit the mesh declares is restored to its declared state at the host's next apply" }; } async journal(scope: Scope, unit: string, lines: number): Promise<{ unit: string; scope: Scope; lines: string[] }> { const args = ["--no-pager", "-n", String(lines), "-u", unit, "-o", "short-iso"]; if (scope === "user") { args.unshift(userInfo().username === this.account ? "--user" : `--machine=${this.account}@`, ...(userInfo().username === this.account ? [] : ["--user"])); } const { stdout } = await run("journalctl", args); return { unit, scope, lines: stdout.split("\n").filter(Boolean) }; } async failed(): Promise<{ system: Unit[]; user: Unit[] }> { const system = (await this.units("system")).filter((u) => u.active === "failed"); const user = (await this.units("user").catch(() => [] as Unit[])).filter((u) => u.active === "failed"); return { system, user }; } }