zsh: hold the mesh's login-shell seat, source the environment, and leave the rest to slots
The seat is now the mesh's node-login-shell, which a shell module claims rather than declares (novox/hq ADR 0204), and the environment is one module's that every module contributes to (ADR 0203). Per hq to-be 41 WP3: - no seat declaration; the claim is node-login-shell serving execute; - EDITOR, VISUAL, XDG_CONFIG_HOME and the three PATH entries are an environment contribution, not exports in the block; - a ~/.zshenv block sources ~/.config/mesh/environment.sh, so a script, a login and execute all see the environment; - the ~/.zshrc block goes at the start, so the operator's lines run after it, and holds today's shared defaults between the first, normal and last slots. The prompt, the plugins and the operator's own lines are no longer in it; - execute is bounded below the runtime's call limit (20 s default, 25 s at most), kills its whole process group on timeout, cuts each stream at 256 KiB and says so, runs in the account's home without the mesh's words, with the account's session words. The dead runuser branch is gone, because the runtime is the account; - zsh_config shows both files with their block line counts; - the README lists the one-off migration (ADR 0182).
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
# zsh
|
||||||
|
|
||||||
|
The login shell as a module (novox/hq ADR 0176, ADR 0204, to-be 41).
|
||||||
|
|
||||||
|
- Installs the `zsh` package and makes it the operator account's login shell through the `user`
|
||||||
|
shape. The host gives the found shell back when the module goes.
|
||||||
|
- Claims the mesh's `node-login-shell` seat and serves its verb `execute`: one command, run as the
|
||||||
|
account in a zsh login shell in the account's home. It is ended with everything it started after
|
||||||
|
20 s by default (25 s at most, below the runtime's 30 s call limit). Each stream is cut at 256 KiB
|
||||||
|
and the answer says so in `truncated`.
|
||||||
|
- Its own tool `zsh_config` shows `~/.zshenv` and `~/.zshrc` as they are, with the mesh block's line
|
||||||
|
count in each.
|
||||||
|
- Contributes to the account's environment (ADR 0203): `EDITOR` and `VISUAL` (vim), `XDG_CONFIG_HOME`,
|
||||||
|
and `~/.local/bin`, `~/scripts`, `~/scripts/bin` at the start of `PATH`. The holder of
|
||||||
|
`node-environment` (`node-env`) writes them; this module writes no `export` of its own.
|
||||||
|
|
||||||
|
## The two blocks
|
||||||
|
|
||||||
|
Each block is the mesh's, between `# BEGIN mesh zsh.<id>` and `# END mesh zsh.<id>`. Every line
|
||||||
|
outside a block is yours, kept byte for byte, and given back when the module goes.
|
||||||
|
|
||||||
|
- **`~/.zshenv`, at the start:** sources `~/.config/mesh/environment.sh` if it is readable. Every zsh
|
||||||
|
reads this file: a login, a script, and `execute`.
|
||||||
|
- **`~/.zshrc`, at the start:** the defaults every machine shares, with the three slots other modules
|
||||||
|
fill (`first`, the defaults, `normal`, `last`). The defaults are the terminal title, seven
|
||||||
|
keybindings, the colour aliases, `ll`/`la`/`l`, `drun`, `disksize` and `sudo-disksize`. Your lines
|
||||||
|
below it run after it, so they win.
|
||||||
|
|
||||||
|
## The one-off migration (ADR 0182)
|
||||||
|
|
||||||
|
The mesh removes nothing it did not make. After the first push that assigns this module, the lines
|
||||||
|
below are in the block **and** in your own part of `~/.zshrc`. Until you delete your copies, they run
|
||||||
|
twice, which is harmless and visible. Deleting them is a person's act, once per machine.
|
||||||
|
|
||||||
|
**Delete once `zsh` is assigned.** The block or the environment now carries these:
|
||||||
|
|
||||||
|
1. `export EDITOR=vim`
|
||||||
|
2. `export VISUAL=vim`
|
||||||
|
3. `export XDG_CONFIG_HOME=$HOME/.config`
|
||||||
|
4. `export PATH="$HOME/.local/bin:$HOME/scripts:$HOME/scripts/bin:$HOME/.dotnet:$PATH"`. Keep the
|
||||||
|
one entry no module carries yet, as `export PATH="$HOME/.dotnet:$PATH"`.
|
||||||
|
5. The terminal title: `function set_terminal_title() { … }` and
|
||||||
|
`precmd_functions+=(set_terminal_title)`.
|
||||||
|
6. The seven `bindkey` lines (Home twice, Ctrl+A, End twice, Ctrl+E, Del).
|
||||||
|
7. `alias drun='docker run -it --rm'`, and the functions `disksize() { … }` and
|
||||||
|
`sudo-disksize() { … }`.
|
||||||
|
8. The `if [ -x /usr/bin/dircolors ]; then … fi` block, with its aliases `ls`, `dir`, `vdir`,
|
||||||
|
`grep`, `fgrep` and `egrep`.
|
||||||
|
9. `alias ll='ls -alhF'`, `alias la='ls -Ah'` and `alias l='ls -CFh'`.
|
||||||
|
|
||||||
|
**Delete once `powerlevel10k` is assigned.** Its contribution in the `normal` slot loads the prompt:
|
||||||
|
|
||||||
|
1. `#user_p10k_cache_file="${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${USER}.zsh"`, and the
|
||||||
|
`if [[ -r "$user_p10k_cache_file" ]]; then … fi` after it. It does nothing today, because the
|
||||||
|
variable is commented out.
|
||||||
|
2. `[[ ! -f ~/.zsh/themes/powerlevel10k/powerlevel10k.zsh-theme ]] || source …`
|
||||||
|
3. `[[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh`
|
||||||
|
|
||||||
|
**Delete once `zsh-autosuggestions` / `zsh-syntax-highlighting` are assigned.** Each module loads
|
||||||
|
the distribution's copy from its own slot:
|
||||||
|
|
||||||
|
1. `[[ ! -f ~/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh ]] || source …`
|
||||||
|
2. `[[ ! -f ~/.zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]] || source …`
|
||||||
|
|
||||||
|
**What stays yours.** Keep these below the block until a module carries them:
|
||||||
|
|
||||||
|
- the `NVM_DIR` lines;
|
||||||
|
- `MY_KV_PATH` and `MY_LIB_PATH`;
|
||||||
|
- `CLAUDE_CODE_DISABLE_TERMINAL_TITLE`;
|
||||||
|
- the `killport` and `findport` aliases;
|
||||||
|
- the `zstyle` lines;
|
||||||
|
- the line sourcing `~/.zshrc.local`, and that file itself.
|
||||||
|
|
||||||
|
**Paths no longer used.** The predecessor cloned these and nothing of the mesh reads them. Remove
|
||||||
|
them by hand once the module that replaces each is assigned:
|
||||||
|
|
||||||
|
- `~/.zsh/themes/powerlevel10k`: replaced by `powerlevel10k`'s pinned copy in
|
||||||
|
`~/.local/share/powerlevel10k`;
|
||||||
|
- `~/.p10k.zsh`: replaced by `~/.config/powerlevel10k/p10k.zsh`, the same bytes, once
|
||||||
|
`powerlevel10k` is assigned;
|
||||||
|
- `~/.zsh/plugins/zsh-autosuggestions` and `~/.zsh/plugins/zsh-syntax-highlighting`: replaced by
|
||||||
|
the distribution's packages;
|
||||||
|
- `~/.zsh/plugins/zsh-autocomplete`: loaded by nothing today.
|
||||||
|
|
||||||
|
Anything else under `~/.zsh/` is yours.
|
||||||
+34
-30
@@ -4,37 +4,9 @@
|
|||||||
"capabilities": [
|
"capabilities": [
|
||||||
"package-manager"
|
"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": [
|
"claims": [
|
||||||
{
|
{
|
||||||
"name": "login-shell",
|
"name": "node-login-shell",
|
||||||
"scope": "node",
|
"scope": "node",
|
||||||
"serves": [
|
"serves": [
|
||||||
"execute"
|
"execute"
|
||||||
@@ -44,12 +16,43 @@
|
|||||||
"tools": [
|
"tools": [
|
||||||
"zsh_config"
|
"zsh_config"
|
||||||
],
|
],
|
||||||
|
"environment": {
|
||||||
|
"variables": {
|
||||||
|
"EDITOR": "vim",
|
||||||
|
"VISUAL": "vim",
|
||||||
|
"XDG_CONFIG_HOME": "${machine:account-home}/.config"
|
||||||
|
},
|
||||||
|
"path": [
|
||||||
|
{
|
||||||
|
"entry": "${machine:account-home}/.local/bin",
|
||||||
|
"at": "start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry": "${machine:account-home}/scripts",
|
||||||
|
"at": "start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry": "${machine:account-home}/scripts/bin",
|
||||||
|
"at": "start"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "package",
|
"id": "package",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"package": "zsh"
|
"package": "zsh"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "env",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${machine:account-home}/.zshenv",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"mode": "0644",
|
||||||
|
"into": "block",
|
||||||
|
"at": "start",
|
||||||
|
"content": "# The mesh's block (module zsh, novox/hq ADR 0203, ADR 0204): the account's environment, read by\n# every zsh — a login, a script, and node-login-shell's execute. Replaced at every push; everything\n# outside it is yours.\nif [[ -r \"${machine:account-home}/.config/mesh/environment.sh\" ]]; then\n source \"${machine:account-home}/.config/mesh/environment.sh\"\nfi\n"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "rc",
|
"id": "rc",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
@@ -57,7 +60,8 @@
|
|||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"into": "block",
|
"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"
|
"at": "start",
|
||||||
|
"content": "# The mesh's block (module zsh, novox/hq ADR 0204): the defaults every machine shares, and the code\n# other modules contribute in three slots. It is replaced at every push. Everything below it is\n# yours, kept as you wrote it, and runs after it, so your lines win.\n\n${shell:zsh:first}\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# Keybindings\nbindkey \"^[[H\" beginning-of-line # Home\nbindkey \"^[OH\" beginning-of-line # Home\nbindkey \"^A\" beginning-of-line # Ctrl + A\nbindkey \"^[[F\" end-of-line # End\nbindkey \"^[OF\" end-of-line # End\nbindkey \"^E\" end-of-line # Ctrl + E\nbindkey \"^[[3~\" delete-char # Del\n\n# Colour support of ls and friends\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 dir='dir --color=auto'\n alias vdir='vdir --color=auto'\n alias grep='grep --color=auto'\n alias fgrep='fgrep --color=auto'\n alias egrep='egrep --color=auto'\nfi\nalias ll='ls -alhF'\nalias la='ls -Ah'\nalias l='ls -CFh'\n\n# A throwaway container, and what takes the space under a directory\nalias drun='docker run -it --rm'\ndisksize() {\n du -h --max-depth=1 \"${1:-.}\" | sort -h\n}\nsudo-disksize() {\n sudo du -h --max-depth=1 \"${1:-.}\" | sort -h\n}\n\n${shell:zsh:normal}${shell:zsh:last}\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "login",
|
"id": "login",
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "@novox/module-zsh",
|
"name": "@novox/module-zsh",
|
||||||
"version": "0.1.0",
|
"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).",
|
"description": "zsh \u2014 the login shell as a module: the package, the login shell set through the user shape, the mesh's blocks at the start of ~/.zshrc (defaults and three slots other modules fill) and in ~/.zshenv (the account's environment), and node-login-shell's execute (novox/hq ADR 0176, ADR 0204).",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc shell.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
|
||||||
|
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// node-login-shell's `execute`, and the reading of the account's zsh files (novox/hq ADR 0176,
|
||||||
|
// ADR 0204).
|
||||||
|
//
|
||||||
|
// Who runs it. The node tools runtime runs as the operator account, not root (novox/hq ADR 0175
|
||||||
|
// §4), and launches this bundle as a process of its own (ADR 0188, ADR 0193). So the command runs
|
||||||
|
// as this process's own user, which is the account; a command that needs root uses sudo inside the
|
||||||
|
// shell, as a person would.
|
||||||
|
//
|
||||||
|
// The bounds are the seat's protocol (ADR 0204 §4), and each answers one way the runtime fails:
|
||||||
|
// - time: the runtime gives up on a call after thirty seconds, so the command is ended before then
|
||||||
|
// and the answer says it timed out, rather than the caller hearing nothing;
|
||||||
|
// - the process group: the shell is started as the leader of its own group and the whole group is
|
||||||
|
// killed on timeout, so `sleep 100 & wait` leaves no sleep behind;
|
||||||
|
// - output: a reply over about a megabyte is lost on the way back, so each stream is cut at a bound
|
||||||
|
// and the answer says it was cut.
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { homedir, userInfo } from "node:os";
|
||||||
|
|
||||||
|
/** The longest a command may run when the caller does not say, and the most it may ask for. Both
|
||||||
|
* below the runtime's thirty-second call limit, leaving room for the answer to travel. */
|
||||||
|
export const DEFAULT_TIMEOUT_SECONDS = 20;
|
||||||
|
export const MAX_TIMEOUT_SECONDS = 25;
|
||||||
|
/** How much of each stream is kept: two of these are far below what one reply carries. */
|
||||||
|
export const OUTPUT_CAP_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
export interface Executed {
|
||||||
|
command: string;
|
||||||
|
account: string;
|
||||||
|
cwd: string;
|
||||||
|
/** The shell's exit status; null when it was ended by a signal. */
|
||||||
|
status: number | null;
|
||||||
|
signal: string | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
/** Which streams were cut at OUTPUT_CAP_BYTES. */
|
||||||
|
truncated: { stdout: boolean; stderr: boolean };
|
||||||
|
timed_out: boolean;
|
||||||
|
timeout_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteOptions {
|
||||||
|
/** The shell to run; zsh for this module, a test may use another. */
|
||||||
|
shell?: string;
|
||||||
|
cwd: string;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
timeoutSeconds: number;
|
||||||
|
capBytes?: number;
|
||||||
|
account?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The timeout a caller asked for, held within the bounds. */
|
||||||
|
export function timeoutOf(asked: unknown): number {
|
||||||
|
if (asked === undefined || asked === null || asked === "") return DEFAULT_TIMEOUT_SECONDS;
|
||||||
|
const n = Number(asked);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT_SECONDS;
|
||||||
|
return Math.min(n, MAX_TIMEOUT_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the command runs: the account's home. */
|
||||||
|
export function homeOf(env: NodeJS.ProcessEnv): string {
|
||||||
|
return env.MESH_OPERATOR_HOME?.trim() || env.HOME?.trim() || homedir();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The environment the command is given: the runtime's, without the mesh's own words, and with the
|
||||||
|
* account's session words when its runtime directory exists, so `systemctl --user` and anything
|
||||||
|
* speaking to the session bus reach the account's own manager. */
|
||||||
|
export function commandEnv(env: NodeJS.ProcessEnv, uid: number | undefined = process.getuid?.(), exists: (p: string) => boolean = existsSync): NodeJS.ProcessEnv {
|
||||||
|
const out: NodeJS.ProcessEnv = {};
|
||||||
|
for (const [k, v] of Object.entries(env)) {
|
||||||
|
if (!k.startsWith("MESH_")) out[k] = v;
|
||||||
|
}
|
||||||
|
if (uid !== undefined) {
|
||||||
|
const runtime = `/run/user/${uid}`;
|
||||||
|
if (exists(runtime)) {
|
||||||
|
out.XDG_RUNTIME_DIR ??= runtime;
|
||||||
|
out.DBUS_SESSION_BUS_ADDRESS ??= `unix:path=${runtime}/bus`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collects one stream up to a bound, and says whether it had to cut. */
|
||||||
|
class Capped {
|
||||||
|
private readonly cap: number;
|
||||||
|
private parts: Buffer[] = [];
|
||||||
|
private size = 0;
|
||||||
|
cut = false;
|
||||||
|
constructor(cap: number) {
|
||||||
|
this.cap = cap;
|
||||||
|
}
|
||||||
|
add(chunk: Buffer): void {
|
||||||
|
const room = this.cap - this.size;
|
||||||
|
if (room <= 0) {
|
||||||
|
this.cut = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kept = chunk.length > room ? chunk.subarray(0, room) : chunk;
|
||||||
|
if (kept.length < chunk.length) this.cut = true;
|
||||||
|
this.parts.push(kept);
|
||||||
|
this.size += kept.length;
|
||||||
|
}
|
||||||
|
text(): string {
|
||||||
|
return Buffer.concat(this.parts).toString("utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run one command line as `<shell> -lc`, bounded in time and output, its process group ended on
|
||||||
|
* timeout. */
|
||||||
|
export function execute(command: string, o: ExecuteOptions): Promise<Executed> {
|
||||||
|
const cap = o.capBytes ?? OUTPUT_CAP_BYTES;
|
||||||
|
const account = o.account ?? userInfo().username;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const out = new Capped(cap);
|
||||||
|
const err = new Capped(cap);
|
||||||
|
let timedOut = false;
|
||||||
|
let settled = false;
|
||||||
|
const child = spawn(o.shell ?? "zsh", ["-lc", command], {
|
||||||
|
cwd: o.cwd,
|
||||||
|
env: o.env,
|
||||||
|
detached: true, // its own process group, so the whole group can be ended
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
const killGroup = () => {
|
||||||
|
try {
|
||||||
|
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// The group is already gone.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
child.stdout.on("data", (d: Buffer) => out.add(d));
|
||||||
|
child.stderr.on("data", (d: Buffer) => err.add(d));
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
killGroup();
|
||||||
|
}, o.timeoutSeconds * 1000);
|
||||||
|
const finish = (status: number | null, signal: string | null, extra = "") => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
child.stdout.destroy();
|
||||||
|
child.stderr.destroy();
|
||||||
|
resolve({
|
||||||
|
command, account, cwd: o.cwd, status, signal,
|
||||||
|
stdout: out.text(), stderr: err.text() + extra,
|
||||||
|
truncated: { stdout: out.cut, stderr: err.cut },
|
||||||
|
timed_out: timedOut, timeout_seconds: o.timeoutSeconds,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
child.on("error", (e) => finish(null, null, e.message));
|
||||||
|
// The shell exiting is the answer. Its streams are read until they close, but a job it left
|
||||||
|
// running in the background may hold them open for ever, so they are given a moment and no more.
|
||||||
|
child.on("exit", (status, signal) => {
|
||||||
|
const grace = setTimeout(() => finish(status, signal), 200);
|
||||||
|
child.on("close", () => {
|
||||||
|
clearTimeout(grace);
|
||||||
|
finish(status, signal);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One zsh startup file as it is now, and how many of its lines are the mesh's block. */
|
||||||
|
export async function zshFile(path: string, cap: number = OUTPUT_CAP_BYTES): Promise<Record<string, unknown>> {
|
||||||
|
const text = await readFile(path, "utf8").catch(() => null);
|
||||||
|
if (text === null) return { path, exists: false };
|
||||||
|
const block = /^# BEGIN mesh [^\n]*\n([\s\S]*?)^# END mesh[^\n]*$/m.exec(text);
|
||||||
|
const lines = text === "" ? 0 : text.replace(/\n$/, "").split("\n").length;
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
exists: true,
|
||||||
|
lines,
|
||||||
|
mesh_block_lines: block && block[1] !== "" ? block[1].replace(/\n$/, "").split("\n").length : 0,
|
||||||
|
truncated: text.length > cap,
|
||||||
|
content: text.length > cap ? text.slice(0, cap) : text,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// node-login-shell's execute over real child processes (novox/hq ADR 0204 §4, to-be 41 WP3): bounded
|
||||||
|
// in time with its whole process group ended, bounded in output and saying so, run in the account's
|
||||||
|
// home, answering the exit status. `sh` stands in for zsh where the test needs no zsh of its own, so
|
||||||
|
// the bounds are proven wherever the tests run; one test runs zsh itself when it is installed.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, readFileSync, realpathSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, commandEnv, execute, homeOf, timeoutOf, zshFile } from "../shell.ts";
|
||||||
|
|
||||||
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "zsh-execute-")));
|
||||||
|
const base = { shell: "sh", cwd: dir, env: { PATH: process.env.PATH, HOME: dir }, timeoutSeconds: 5 };
|
||||||
|
|
||||||
|
function alive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gone(pid: number, ms = 2000): Promise<boolean> {
|
||||||
|
const until = Date.now() + ms;
|
||||||
|
while (Date.now() < until) {
|
||||||
|
if (!alive(pid)) return true;
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
return !alive(pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the exit status and both streams are answered", async () => {
|
||||||
|
const r = await execute("echo out; echo err >&2; exit 3", base);
|
||||||
|
assert.equal(r.status, 3);
|
||||||
|
assert.equal(r.stdout, "out\n");
|
||||||
|
assert.equal(r.stderr, "err\n");
|
||||||
|
assert.equal(r.timed_out, false);
|
||||||
|
assert.deepEqual(r.truncated, { stdout: false, stderr: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it runs in the directory it is given", async () => {
|
||||||
|
const r = await execute("pwd", base);
|
||||||
|
assert.equal(r.stdout.trim(), dir);
|
||||||
|
assert.equal(r.cwd, dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("on timeout the whole process group is ended, a backgrounded grandchild included", async () => {
|
||||||
|
const pidfile = join(dir, "grandchild.pid");
|
||||||
|
const started = Date.now();
|
||||||
|
const r = await execute(`sh -c 'sleep 100 & echo $! > ${pidfile}; wait'`, { ...base, timeoutSeconds: 0.5 });
|
||||||
|
assert.equal(r.timed_out, true);
|
||||||
|
assert.ok(Date.now() - started < 5000, "answered soon after the timeout, not when the sleep ended");
|
||||||
|
assert.equal(r.status, null);
|
||||||
|
assert.equal(r.signal, "SIGKILL");
|
||||||
|
const pid = Number(readFileSync(pidfile, "utf8").trim());
|
||||||
|
assert.ok(pid > 0);
|
||||||
|
assert.ok(await gone(pid), `the grandchild ${pid} is gone`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("output beyond the bound is cut and the answer says so", async () => {
|
||||||
|
const r = await execute("head -c 300000 /dev/zero | tr '\\0' a; head -c 10 /dev/zero | tr '\\0' b >&2", { ...base, capBytes: 1024 });
|
||||||
|
assert.equal(r.stdout.length, 1024);
|
||||||
|
assert.equal(r.truncated.stdout, true);
|
||||||
|
assert.equal(r.stderr, "bbbbbbbbbb");
|
||||||
|
assert.equal(r.truncated.stderr, false);
|
||||||
|
assert.equal(r.status, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a job left in the background does not hold the answer", async () => {
|
||||||
|
const pidfile = join(dir, "background.pid");
|
||||||
|
const started = Date.now();
|
||||||
|
const r = await execute(`sleep 30 & echo $! > ${pidfile}; echo done`, base);
|
||||||
|
assert.ok(Date.now() - started < 3000);
|
||||||
|
assert.equal(r.stdout, "done\n");
|
||||||
|
assert.equal(r.status, 0);
|
||||||
|
process.kill(Number(readFileSync(pidfile, "utf8").trim()), "SIGKILL");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zsh runs the command as a login shell, when zsh is here", async (t) => {
|
||||||
|
try {
|
||||||
|
execFileSync("zsh", ["-c", "true"]);
|
||||||
|
} catch {
|
||||||
|
t.skip("zsh is not installed here");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const home = join(dir, "home");
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
writeFileSync(join(home, ".zshenv"), "export FROM_ZSHENV=yes\n");
|
||||||
|
const r = await execute("print -r -- $FROM_ZSHENV; [[ -o login ]] && print login", { cwd: home, env: { PATH: process.env.PATH, HOME: home, ZDOTDIR: home }, timeoutSeconds: 5 });
|
||||||
|
assert.equal(r.stdout, "yes\nlogin\n");
|
||||||
|
assert.equal(r.status, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the timeout is held below the runtime's call limit", () => {
|
||||||
|
assert.equal(timeoutOf(undefined), DEFAULT_TIMEOUT_SECONDS);
|
||||||
|
assert.equal(timeoutOf(5), 5);
|
||||||
|
assert.equal(timeoutOf(600), MAX_TIMEOUT_SECONDS);
|
||||||
|
assert.equal(timeoutOf(-1), DEFAULT_TIMEOUT_SECONDS);
|
||||||
|
assert.equal(timeoutOf("x"), DEFAULT_TIMEOUT_SECONDS);
|
||||||
|
assert.ok(MAX_TIMEOUT_SECONDS < 30 && DEFAULT_TIMEOUT_SECONDS <= MAX_TIMEOUT_SECONDS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the command's environment drops the mesh's words and gains the account's session words when they exist", () => {
|
||||||
|
const env = { PATH: "/usr/bin", HOME: "/home/op", MESH_OPERATOR_ACCOUNT: "op", MESH_OPERATOR_HOME: "/home/op", MESH_ANYTHING: "x" };
|
||||||
|
const there = commandEnv(env, 1000, (p) => p === "/run/user/1000");
|
||||||
|
assert.deepEqual(there, { PATH: "/usr/bin", HOME: "/home/op", XDG_RUNTIME_DIR: "/run/user/1000", DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus" });
|
||||||
|
const absent = commandEnv(env, 1000, () => false);
|
||||||
|
assert.deepEqual(absent, { PATH: "/usr/bin", HOME: "/home/op" });
|
||||||
|
assert.equal(homeOf(env), "/home/op");
|
||||||
|
assert.equal(homeOf({ HOME: "/h" }), "/h");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zsh_config counts the mesh's block in a file", async () => {
|
||||||
|
const path = join(dir, ".zshrc");
|
||||||
|
writeFileSync(path, "# BEGIN mesh zsh.rc\na\nb\nc\n# END mesh zsh.rc\nmine\n");
|
||||||
|
const r = await zshFile(path);
|
||||||
|
assert.equal(r.lines, 6);
|
||||||
|
assert.equal(r.mesh_block_lines, 3);
|
||||||
|
assert.deepEqual(await zshFile(join(dir, "absent")), { path: join(dir, "absent"), exists: false });
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// The zsh module's shape (novox/hq ADR 0203, ADR 0204, to-be 41 WP3): it claims the mesh's
|
||||||
|
// node-login-shell and declares no seat; its ~/.zshrc block goes at the start and holds the three
|
||||||
|
// slots in order; its ~/.zshenv block sources the path node-environment fixes and nothing else; its
|
||||||
|
// environment is a contribution, never an export in its own lines.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const m = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
|
||||||
|
const resource = (id: string) => m.resources.find((r: { id: string }) => r.id === id);
|
||||||
|
|
||||||
|
test("it claims node-login-shell serving execute, and declares no seat", () => {
|
||||||
|
assert.equal(m.seats, undefined);
|
||||||
|
assert.deepEqual(m.claims, [{ name: "node-login-shell", scope: "node", serves: ["execute"] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the .zshrc block is at the start and holds the three slots in order, after the defaults' header", () => {
|
||||||
|
const rc = resource("rc");
|
||||||
|
assert.equal(rc.path, "${machine:account-home}/.zshrc");
|
||||||
|
assert.equal(rc.into, "block");
|
||||||
|
assert.equal(rc.at, "start");
|
||||||
|
const c: string = rc.content;
|
||||||
|
const first = c.indexOf("${shell:zsh:first}");
|
||||||
|
const normal = c.indexOf("${shell:zsh:normal}");
|
||||||
|
const last = c.indexOf("${shell:zsh:last}");
|
||||||
|
assert.ok(c.startsWith("# The mesh's block"), "it opens by saying whose it is");
|
||||||
|
assert.ok(first > 0 && first < normal && normal < last, "first, normal, last, in that order");
|
||||||
|
assert.ok(c.indexOf("set_terminal_title") > first && c.indexOf("sudo-disksize") < normal, "the defaults sit between first and normal");
|
||||||
|
for (const slot of ["first", "normal", "last"]) assert.equal(c.split(`\${shell:zsh:${slot}}`).length, 2, `${slot} appears once`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the .zshrc block carries the shared defaults and none of the operator's own lines", () => {
|
||||||
|
const c: string = resource("rc").content;
|
||||||
|
for (const want of ["precmd_functions+=(set_terminal_title)", "alias egrep='egrep --color=auto'", "alias ll='ls -alhF'", "alias drun=", "disksize()", "sudo-disksize()"]) {
|
||||||
|
assert.ok(c.includes(want), want);
|
||||||
|
}
|
||||||
|
assert.equal((c.match(/^bindkey /gm) ?? []).length, 7);
|
||||||
|
for (const not of ["export ", "p10k", "powerlevel10k", "nvm", ".dotnet", "MY_KV_PATH", "MY_LIB_PATH", "CLAUDE_CODE_DISABLE_TERMINAL_TITLE", "killport", "findport", ".zshrc.local", "zsh-autosuggestions", "zsh-syntax-highlighting"]) {
|
||||||
|
assert.ok(!c.includes(not), `the block holds no ${not}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the .zshenv block sources exactly the environment file node-environment fixes", () => {
|
||||||
|
const env = resource("env");
|
||||||
|
assert.equal(env.path, "${machine:account-home}/.zshenv");
|
||||||
|
assert.equal(env.into, "block");
|
||||||
|
assert.equal(env.at, "start");
|
||||||
|
const sourced = [...(env.content as string).matchAll(/^\s*(?:source|\.)\s+"?([^"\s]+)"?/gm)].map((x) => x[1]);
|
||||||
|
assert.deepEqual(sourced, ["${machine:account-home}/.config/mesh/environment.sh"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("its environment is a contribution: the editor, the configuration home, and three PATH entries at the start", () => {
|
||||||
|
assert.deepEqual(m.environment.variables, { EDITOR: "vim", VISUAL: "vim", XDG_CONFIG_HOME: "${machine:account-home}/.config" });
|
||||||
|
assert.deepEqual(m.environment.path, [
|
||||||
|
{ entry: "${machine:account-home}/.local/bin", at: "start" },
|
||||||
|
{ entry: "${machine:account-home}/scripts", at: "start" },
|
||||||
|
{ entry: "${machine:account-home}/scripts/bin", at: "start" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it keeps the package and sets the login shell through the user shape", () => {
|
||||||
|
assert.deepEqual(resource("package"), { id: "package", type: "package", package: "zsh" });
|
||||||
|
assert.deepEqual(resource("login"), { id: "login", type: "user", name: "${machine:account}", shell: "/usr/bin/zsh" });
|
||||||
|
assert.ok(m.capabilities.includes("package-manager"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the tools bundle registers the seat's verb under node-login-shell", () => {
|
||||||
|
const src = readFileSync(new URL("../tools/index.ts", import.meta.url), "utf8");
|
||||||
|
assert.match(src, /registerModuleTools\("node-login-shell", seatVerbs\)/);
|
||||||
|
assert.doesNotMatch(src, /runuser|"login-shell"/);
|
||||||
|
});
|
||||||
+24
-59
@@ -1,74 +1,43 @@
|
|||||||
// zsh's tools — the module's own, and its implementation of the login-shell seat's one verb
|
// zsh's tools: its implementation of node-login-shell's one verb, `execute`, and its own
|
||||||
// (novox/hq ADR 0176). Served by the node tools runtime (ADR 0175); nothing here runs a process.
|
// `zsh_config` (novox/hq ADR 0176, ADR 0204). The node tools runtime launches this bundle as a
|
||||||
//
|
// process of its own and serves what it registers (ADR 0188, ADR 0193); it runs as the operator
|
||||||
// `execute` runs as the operator account. The runtime runs as the node's account — root when the
|
// account, so the command runs as that account with nothing switched (shell.ts).
|
||||||
// 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 { userInfo } from "node:os";
|
||||||
import { readFile } from "node:fs/promises";
|
import { join } from "node:path";
|
||||||
import { homedir, userInfo } from "node:os";
|
|
||||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
|
import { DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, commandEnv, execute, homeOf, timeoutOf, zshFile } from "../shell.js";
|
||||||
|
|
||||||
/** The operator account on this machine, as the mesh told the runtime; the current user otherwise. */
|
/** The operator account on this machine, as the mesh told the runtime. */
|
||||||
function account(env: NodeJS.ProcessEnv): string {
|
function account(env: NodeJS.ProcessEnv): string {
|
||||||
return env.MESH_OPERATOR_ACCOUNT?.trim() || userInfo().username;
|
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[] {
|
function seatVerbs(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: "execute",
|
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.",
|
description:
|
||||||
|
`Run one command on this machine as the operator account, in a zsh login shell in the account's home; answers with what it printed (each stream cut at 256 KiB, said in truncated) and how it exited. Ended, with everything it started, after timeout_seconds (default ${DEFAULT_TIMEOUT_SECONDS}, at most ${MAX_TIMEOUT_SECONDS}).`,
|
||||||
input: {
|
input: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
command: { type: "string", description: "the command line, as you would type it" },
|
command: { type: "string", description: "the command line, as you would type it" },
|
||||||
timeout_seconds: { type: "number", description: "give up after this long (default 60)" },
|
timeout_seconds: { type: "number", description: `give up after this long (default ${DEFAULT_TIMEOUT_SECONDS}, at most ${MAX_TIMEOUT_SECONDS})` },
|
||||||
},
|
},
|
||||||
required: ["command"],
|
required: ["command"],
|
||||||
},
|
},
|
||||||
run: async (args) => {
|
run: async (args) => {
|
||||||
const command = String(args.command ?? "").trim();
|
const command = String(args.command ?? "").trim();
|
||||||
if (!command) throw new Error("execute: a command is required");
|
if (!command) throw new Error("execute: a command is required");
|
||||||
const timeout = Number(args.timeout_seconds ?? 60);
|
const who = account(env);
|
||||||
return execute(command, account(env), Number.isFinite(timeout) && timeout > 0 ? timeout : 60);
|
const self = userInfo().username;
|
||||||
|
if (who !== self) {
|
||||||
|
// The runtime is the account; anything else is a runtime this was not written for, and
|
||||||
|
// a command run as the wrong user is worse than one not run.
|
||||||
|
throw new Error(`execute runs as ${who}, and this runtime runs as ${self}`);
|
||||||
|
}
|
||||||
|
return execute(command, { cwd: homeOf(env), env: commandEnv(env), timeoutSeconds: timeoutOf(args.timeout_seconds), account: who });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -78,21 +47,17 @@ function ownTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: "zsh_config",
|
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.",
|
description: "The operator account's ~/.zshenv and ~/.zshrc on this machine as they are now: each file's lines, how many are the mesh's block, and the content.",
|
||||||
input: { type: "object", properties: {} },
|
input: { type: "object", properties: {} },
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const who = account(env);
|
const home = homeOf(env);
|
||||||
const home = env.MESH_OPERATOR_HOME?.trim() || (who === userInfo().username ? homedir() : `/home/${who}`);
|
return { account: account(env), zshenv: await zshFile(join(home, ".zshenv")), zshrc: await zshFile(join(home, ".zshrc")) };
|
||||||
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
|
// 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.
|
// subject while this module holds it) and the module's own tool under the module's.
|
||||||
registerModuleTools("login-shell", seatVerbs);
|
registerModuleTools("node-login-shell", seatVerbs);
|
||||||
registerModuleTools("zsh", ownTools);
|
registerModuleTools("zsh", ownTools);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
"shell.ts",
|
||||||
"tools/index.ts"
|
"tools/index.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user