Compare commits

...
2 Commits
Author SHA1 Message Date
jochen 9ab58e79fb systemd: the service manager as a module — holds node-service-manager and answers for the units in both scopes
The holder of the seat the controller seeds under novox/hq ADR 0177. Eight
verbs under the seat's name — units, status, start, stop, restart, enable,
disable, journal — each taking an optional scope, "system" by default or
"user" for the operator account's own manager, reached as
`systemctl --user --machine=<account>@` when the runtime is not that account.
One tool of its own, systemd_failed, for every failed unit in both scopes.
A package, a claim and a bundle; no container, no process: served by the node
tools runtime (ADR 0175) once it exists. `module check` passes against a
controller that carries the seat; the tools type-check against the SDK.
2026-10-02 16:47:48 +02:00
jochen a263bc36ba zsh: the shell as a module — package, the mesh's ~/.zshrc block, the login-shell seat and execute
The first module of the operator's environment (novox/hq to-be 37 §1, ADR 0173,
0176). A package, the mesh's default configuration as a block inside the
account's ~/.zshrc so the operator's own lines around it survive every push
(ADR 0174 as the host's `into: block` realises it), a `user` shape that makes
zsh the account's login shell, the `login-shell` seat declared with its one
verb, and a tools bundle: `execute` under the seat's name, `zsh_config` under
the module's. No container, no process: the tools are served by the node tools
runtime (ADR 0175), which does not exist yet — the bundle builds and the
manifest registers ahead of it. `module check` passes; the tools type-check
against the SDK.

Two things the manifest cannot yet say, left for the controller: the `user`
shape applies wherever the module is assigned, not only where it holds the
seat; and the runtime learns the account from MESH_OPERATOR_ACCOUNT, which
nothing sets yet.
2026-10-02 16:40:34 +02:00
9 changed files with 446 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
// 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=<account>@` 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<Unit[]> {
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<Record<string, string>> {
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<string, string> = { 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<Record<string, unknown>> {
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 };
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"module": "systemd",
"version": "1",
"capabilities": [
"service-manager",
"package-manager"
],
"claims": [
{
"name": "node-service-manager",
"scope": "node",
"serves": [
"units",
"status",
"start",
"stop",
"restart",
"enable",
"disable",
"journal"
]
}
],
"tools": [
"systemd_failed"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "systemd"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
]
}
]
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-systemd",
"version": "0.1.0",
"description": "systemd \u2014 the machine's service manager as a module: holds node-service-manager and answers for the units in both scopes (novox/hq ADR 0177). The host applies units; this answers about them.",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+71
View File
@@ -0,0 +1,71 @@
// systemd's tools: the node-service-manager seat's eight verbs — the units on this machine in
// both scopes, read and acted on by name — and the module's own reading of what has failed
// (novox/hq ADR 0177). Served by the node tools runtime (ADR 0175); the host applies units, this
// answers about them.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { ServiceManager, type Scope } from "../client.js";
const scope = { type: "string", description: "\"system\" (the default) or \"user\": the operator account's own manager" };
const unit = { type: "string", description: "the unit's name, as the service manager knows it" };
function scopeOf(args: Readonly<Record<string, unknown>>): Scope {
const s = String(args.scope ?? "system");
if (s !== "system" && s !== "user") throw new Error(`scope ${JSON.stringify(s)}: "system" or "user"`);
return s;
}
function unitOf(args: Readonly<Record<string, unknown>>): string {
const u = String(args.unit ?? "").trim();
if (!u) throw new Error("a unit is required");
return u;
}
export function getSeatVerbs(manager: ServiceManager): ToolDefinition[] {
const act = (verb: "start" | "stop" | "restart" | "enable" | "disable", description: string): ToolDefinition => ({
name: verb,
description,
input: { type: "object", properties: { scope, unit }, required: ["unit"] },
run: async (args) => manager.act(scopeOf(args), verb, unitOf(args)),
});
return [
{
name: "units",
description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.",
input: { type: "object", properties: { scope, pattern: { type: "string", description: "a glob the unit's name must match (optional)" } } },
run: async (args) => ({ scope: scopeOf(args), units: await manager.units(scopeOf(args), args.pattern ? String(args.pattern) : undefined) }),
},
{
name: "status",
description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.",
input: { type: "object", properties: { scope, unit }, required: ["unit"] },
run: async (args) => manager.status(scopeOf(args), unitOf(args)),
},
act("start", "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply."),
act("stop", "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state."),
act("restart", "Restart one unit."),
act("enable", "Make one unit start at boot (or at the account's login, in user scope)."),
act("disable", "Stop one unit starting at boot (or at login, in user scope)."),
{
name: "journal",
description: "The last lines of one unit's journal.",
input: { type: "object", properties: { scope, unit, lines: { type: "number", description: "how many lines from the end (default 100)" } }, required: ["unit"] },
run: async (args) => {
const n = Number(args.lines ?? 100);
return manager.journal(scopeOf(args), unitOf(args), Number.isFinite(n) && n > 0 ? Math.min(n, 5000) : 100);
},
},
];
}
export function getOwnTools(manager: ServiceManager): ToolDefinition[] {
return [
{
name: "systemd_failed",
description: "Every failed unit on this machine, in the system manager and in the operator account's.",
input: { type: "object", properties: {} },
run: async () => manager.failed(),
},
];
}
registerModuleTools("node-service-manager", (env) => getSeatVerbs(ServiceManager.fromEnv(env)));
registerModuleTools("systemd", (env) => getOwnTools(ServiceManager.fromEnv(env)));
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"tools/index.ts",
"client.ts"
]
}
+81
View File
@@ -0,0 +1,81 @@
{
"module": "zsh",
"version": "1",
"capabilities": [
"package-manager"
],
"seats": [
{
"name": "login-shell",
"scope": "node",
"serves": [
{
"name": "execute",
"description": "Run one command on this machine as the operator account, in a login shell; answers with what it printed and how it exited (novox/hq ADR 0176).",
"input": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "the command line, as you would type it"
},
"timeout_seconds": {
"type": "number",
"description": "give up after this long (default 60)"
}
},
"required": [
"command"
]
}
}
]
}
],
"claims": [
{
"name": "login-shell",
"scope": "node",
"serves": [
"execute"
]
}
],
"tools": [
"zsh_config"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "zsh"
},
{
"id": "rc",
"type": "file",
"path": "${machine:account-home}/.zshrc",
"owner": "${machine:account}",
"mode": "0644",
"into": "block",
"content": "# The mesh's default zsh configuration (module zsh). Everything OUTSIDE this block is yours and\n# survives every push; everything inside it is replaced on the next one (novox/hq ADR 0174).\n# Machine-specific lines go in ~/.zshrc.local, which this sources last.\n\nexport EDITOR=vim\nexport VISUAL=vim\nexport XDG_CONFIG_HOME=\"$HOME/.config\"\nexport PATH=\"$HOME/.local/bin:$HOME/scripts:$HOME/scripts/bin:$PATH\"\n\n# Terminal title: host, directory, git branch\nfunction set_terminal_title() {\n local git_branch=\"\"\n if git rev-parse --is-inside-work-tree &>/dev/null; then\n git_branch=\" ($(git branch --show-current 2>/dev/null))\"\n fi\n print -Pn \"\\e]2;%m: %~${git_branch}\\a\"\n}\nprecmd_functions+=(set_terminal_title)\n\n# A prompt theme and plugins, when a module placed them (the prompt module owns ~/.p10k.zsh and\n# ~/.zsh/themes; this only loads what is there).\n[[ ! -f ~/.zsh/themes/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.zsh/themes/powerlevel10k/powerlevel10k.zsh-theme\n[[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh\n[[ ! -f ~/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh ]] || source ~/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n[[ ! -f ~/.zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]] || source ~/.zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n\n# Keybindings: Home, End, Ctrl-A, Ctrl-E, Del\nbindkey \"^[[H\" beginning-of-line\nbindkey \"^[OH\" beginning-of-line\nbindkey \"^A\" beginning-of-line\nbindkey \"^[[F\" end-of-line\nbindkey \"^[OF\" end-of-line\nbindkey \"^E\" end-of-line\nbindkey \"^[[3~\" delete-char\n\n# Colour and the usual ls aliases\nif [ -x /usr/bin/dircolors ]; then\n test -r \"$HOME/.dircolors\" && eval \"$(dircolors -b \"$HOME/.dircolors\")\" || eval \"$(dircolors -b)\"\n alias ls='ls --color=auto'\n alias grep='grep --color=auto'\nfi\nalias ll='ls -alhF'\nalias la='ls -Ah'\nalias l='ls -CFh'\nalias drun='docker run -it --rm'\ndisksize() { du -h --max-depth=1 \"${1:-.}\" | sort -h; }\n\n# Machine-specific configuration, kept by you\n[[ ! -f ~/.zshrc.local ]] || source ~/.zshrc.local\n"
},
{
"id": "login",
"type": "user",
"name": "${machine:account}",
"shell": "/usr/bin/zsh"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
]
}
]
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-zsh",
"version": "0.1.0",
"description": "zsh \u2014 the shell as a module: the package, the mesh's default ~/.zshrc as a block the operator's own lines survive around, the login-shell seat and its execute verb (novox/hq ADR 0176).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+98
View File
@@ -0,0 +1,98 @@
// zsh's tools — the module's own, and its implementation of the login-shell seat's one verb
// (novox/hq ADR 0176). Served by the node tools runtime (ADR 0175); nothing here runs a process.
//
// `execute` runs as the operator account. The runtime runs as the node's account — root when the
// host started it — so the command is handed to the account through `runuser` when we are not
// already that account. Root is the module's concern (ADR 0175 §4): a command that needs it uses
// sudo inside the shell like a person would.
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import { homedir, userInfo } from "node:os";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
/** The operator account on this machine, as the mesh told the runtime; the current user otherwise. */
function account(env: NodeJS.ProcessEnv): string {
return env.MESH_OPERATOR_ACCOUNT?.trim() || userInfo().username;
}
interface Executed {
command: string;
account: string;
status: number | null;
signal: string | null;
stdout: string;
stderr: string;
timed_out: boolean;
}
/** Run one command line in a zsh login shell as the account, capturing everything. */
export async function execute(command: string, who: string, timeoutSeconds: number): Promise<Executed> {
const self = userInfo().username;
const argv = who === self
? ["zsh", "-lc", command]
: ["runuser", "-u", who, "--", "zsh", "-lc", command];
return new Promise((resolve) => {
const child = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let timedOut = false;
child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
child.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutSeconds * 1000);
child.on("error", (err) => {
clearTimeout(timer);
resolve({ command, account: who, status: null, signal: null, stdout, stderr: stderr + err.message, timed_out: false });
});
child.on("close", (status, signal) => {
clearTimeout(timer);
resolve({ command, account: who, status, signal, stdout, stderr, timed_out: timedOut });
});
});
}
function seatVerbs(env: NodeJS.ProcessEnv): ToolDefinition[] {
return [
{
name: "execute",
description: "Run one command on this machine as the operator account, in a login shell; answers with what it printed and how it exited.",
input: {
type: "object",
properties: {
command: { type: "string", description: "the command line, as you would type it" },
timeout_seconds: { type: "number", description: "give up after this long (default 60)" },
},
required: ["command"],
},
run: async (args) => {
const command = String(args.command ?? "").trim();
if (!command) throw new Error("execute: a command is required");
const timeout = Number(args.timeout_seconds ?? 60);
return execute(command, account(env), Number.isFinite(timeout) && timeout > 0 ? timeout : 60);
},
},
];
}
function ownTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
return [
{
name: "zsh_config",
description: "The operator account's ~/.zshrc on this machine as it is now: the mesh's block and the lines around it.",
input: { type: "object", properties: {} },
run: async () => {
const who = account(env);
const home = env.MESH_OPERATOR_HOME?.trim() || (who === userInfo().username ? homedir() : `/home/${who}`);
const path = `${home}/.zshrc`;
const text = await readFile(path, "utf8").catch(() => "");
const inBlock = /# BEGIN mesh [^\n]*\n([\s\S]*?)# END mesh/.exec(text);
return { account: who, path, lines: text.split("\n").length, mesh_block_lines: inBlock ? inBlock[1].split("\n").length - 1 : 0, content: text };
},
},
];
}
// The seat's verb is registered under the seat's name (what the runtime serves on the seat's
// subject when this module holds it) and the module's own tools under the module's.
registerModuleTools("login-shell", seatVerbs);
registerModuleTools("zsh", ownTools);
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"tools/index.ts"
]
}