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.
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"tools/index.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user