From a263bc36ba1c666d6b17816cd2583f685e713402 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 16:40:34 +0200 Subject: [PATCH 1/2] =?UTF-8?q?zsh:=20the=20shell=20as=20a=20module=20?= =?UTF-8?q?=E2=80=94=20package,=20the=20mesh's=20~/.zshrc=20block,=20the?= =?UTF-8?q?=20login-shell=20seat=20and=20execute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- modules/zsh/module.json | 81 +++++++++++++++++++++++++++++++ modules/zsh/package.json | 14 ++++++ modules/zsh/tools/index.ts | 98 ++++++++++++++++++++++++++++++++++++++ modules/zsh/tsconfig.json | 14 ++++++ 4 files changed, 207 insertions(+) create mode 100644 modules/zsh/module.json create mode 100644 modules/zsh/package.json create mode 100644 modules/zsh/tools/index.ts create mode 100644 modules/zsh/tsconfig.json diff --git a/modules/zsh/module.json b/modules/zsh/module.json new file mode 100644 index 0000000..1cd43b3 --- /dev/null +++ b/modules/zsh/module.json @@ -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" + ] + } + ] + } +} diff --git a/modules/zsh/package.json b/modules/zsh/package.json new file mode 100644 index 0000000..00bbc02 --- /dev/null +++ b/modules/zsh/package.json @@ -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" + } +} diff --git a/modules/zsh/tools/index.ts b/modules/zsh/tools/index.ts new file mode 100644 index 0000000..7058fd5 --- /dev/null +++ b/modules/zsh/tools/index.ts @@ -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 { + 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); diff --git a/modules/zsh/tsconfig.json b/modules/zsh/tsconfig.json new file mode 100644 index 0000000..bccdc23 --- /dev/null +++ b/modules/zsh/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "tools/index.ts" + ] +} -- 2.54.0 From 9ab58e79fb495658c95263abe4be2f5bf726a948 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 16:47:48 +0200 Subject: [PATCH 2/2] =?UTF-8?q?systemd:=20the=20service=20manager=20as=20a?= =?UTF-8?q?=20module=20=E2=80=94=20holds=20node-service-manager=20and=20an?= =?UTF-8?q?swers=20for=20the=20units=20in=20both=20scopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=@` 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. --- modules/systemd/client.ts | 93 ++++++++++++++++++++++++++++++++++ modules/systemd/module.json | 46 +++++++++++++++++ modules/systemd/package.json | 14 +++++ modules/systemd/tools/index.ts | 71 ++++++++++++++++++++++++++ modules/systemd/tsconfig.json | 15 ++++++ 5 files changed, 239 insertions(+) create mode 100644 modules/systemd/client.ts create mode 100644 modules/systemd/module.json create mode 100644 modules/systemd/package.json create mode 100644 modules/systemd/tools/index.ts create mode 100644 modules/systemd/tsconfig.json diff --git a/modules/systemd/client.ts b/modules/systemd/client.ts new file mode 100644 index 0000000..04ebd80 --- /dev/null +++ b/modules/systemd/client.ts @@ -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=@` 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 }; + } +} diff --git a/modules/systemd/module.json b/modules/systemd/module.json new file mode 100644 index 0000000..cb2f495 --- /dev/null +++ b/modules/systemd/module.json @@ -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" + ] + } + ] + } +} diff --git a/modules/systemd/package.json b/modules/systemd/package.json new file mode 100644 index 0000000..bccb3bf --- /dev/null +++ b/modules/systemd/package.json @@ -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" + } +} diff --git a/modules/systemd/tools/index.ts b/modules/systemd/tools/index.ts new file mode 100644 index 0000000..43d1a0f --- /dev/null +++ b/modules/systemd/tools/index.ts @@ -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>): 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>): 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))); diff --git a/modules/systemd/tsconfig.json b/modules/systemd/tsconfig.json new file mode 100644 index 0000000..862dc1f --- /dev/null +++ b/modules/systemd/tsconfig.json @@ -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" + ] +} -- 2.54.0