diff --git a/scripts/setup.sh b/scripts/setup.sh index 943ed839..5f466013 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -14,7 +14,7 @@ # Postgres, pm2 and the two agent CLIs, then starts ecosystem.light.config.cjs. # # Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, Go, Rust, -# PulseAudio, cliamp, Neovim, the shell tooling (starship/oh-my-zsh/eza/lazygit), yt-dlp, and +# PulseAudio, cliamp, Neovim, the shell extras (oh-my-zsh/eza/lazygit), yt-dlp, and # the remote desktop. Of the Docker services only Postgres is brought up. # # The app itself is identical — every API route stays mounted, so the features whose sidecars @@ -491,13 +491,50 @@ else skip "bun symlink at /usr/local/bin/bun" fi +# ─── 6b. Starship prompt ────────────────────────────────────────────────────── +# +# Outside the light-profile skip below, unlike the rest of the terminal tooling. The light profile exists to +# serve a file browser, a terminal and chat — the terminal is one of its three reasons to be, and it is also +# what every member gets when per-user Linux accounts are on. `src/servers/shell-skel/zshrc` deploys this same +# prompt to every account, so leaving starship out of light meant every member's shell fell back to the plain +# one on exactly the installs most likely to have members. +# +# One static binary and one config file. oh-my-zsh, eza and lazygit stay in section 12, where `light` skips +# them: those are host comforts, and the shell template treats each as optional. +echo "" +echo "── Prompt (starship) ──" + +# Starship prompt +if has starship; then + skip "starship" +else + curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin + if has starship; then ok "starship installed"; else warn "starship install failed"; fi +fi + +# Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on +# every run, silently — the nvim step below already gets this right by guarding on the config's +# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user +# wrote when there is. +mkdir -p "$HOME/.config" +STARSHIP_DEST="$HOME/.config/starship.toml" +if [ ! -f "$STARSHIP_DEST" ]; then + cp "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST" + ok "starship config deployed" +elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then + skip "starship config" +else + warn "starship config kept — yours differs (cp scripts/starship.toml ~/.config/ to take this one)" +fi + + # Sections 7-13 are one block because `light` skips all of them. Go and PulseAudio exist to build and # feed cliamp; Rust has no consumer left in the tree; Neovim, the shell tooling and yt-dlp are host # comforts and capability dependencies rather than anything the app needs to serve a file browser, a # terminal and a chat. if is_light; then echo "" - omit "Go, Rust, PulseAudio, cliamp, Neovim, shell tooling (starship/oh-my-zsh/eza/lazygit), yt-dlp" + omit "Go, Rust, PulseAudio, cliamp, Neovim, shell extras (oh-my-zsh/eza/lazygit), yt-dlp" else # ─── 7. Go ───────────────────────────────────────────────────────────────────── @@ -684,32 +721,10 @@ else ok "LazyVim starter installed at ~/.config/nvim" fi -# ─── 12. Terminal tools ────────────────────────────────────────────────────── +# ─── 12. Terminal tools (starship is section 6b, outside the light skip) ───── echo "" -echo "── Terminal tools (starship, oh-my-zsh, eza, lazygit) ──" +echo "── Terminal tools (oh-my-zsh, eza, lazygit) ──" -# Starship prompt -if has starship; then - skip "starship" -else - curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin - if has starship; then ok "starship installed"; else warn "starship install failed"; fi -fi - -# Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on -# every run, silently — the nvim step below already gets this right by guarding on the config's -# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user -# wrote when there is. -mkdir -p "$HOME/.config" -STARSHIP_DEST="$HOME/.config/starship.toml" -if [ ! -f "$STARSHIP_DEST" ]; then - cp "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST" - ok "starship config deployed" -elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then - skip "starship config" -else - warn "starship config kept — yours differs (cp scripts/starship.toml ~/.config/ to take this one)" -fi # Oh-My-Zsh if [ -d "$HOME/.oh-my-zsh" ]; then diff --git a/src/servers/api/users/provision-os.ts b/src/servers/api/users/provision-os.ts index b1126ccc..4b5899e4 100644 --- a/src/servers/api/users/provision-os.ts +++ b/src/servers/api/users/provision-os.ts @@ -1,6 +1,7 @@ import { updateUser } from 'officerdb'; import { OS_USERS_ENABLED, ensureOsUser } from '@@/os-user'; import { provisionSshAccess } from '@@/os-user-ssh'; +import { seedShellConfig } from '@@/os-user-shell'; import { provisionUserDirs } from '@@/data-path'; // Giving an account its Linux side: the directory skeleton, the Linux user, the confinement, the keys. @@ -67,10 +68,16 @@ export async function provisionOsAccount(params: { authorizedKey: params.inboundKey, }); + // The shell configuration. Last because it is the only step whose failure leaves nothing broken — the + // account works, the keys work, the terminal opens; it just opens with zsh's bare defaults. + const shell = await seedShellConfig({ email: params.email, uid: account.uid, gid: account.gid }); + // The Linux account is recorded either way: it exists, it is confined, and a member's terminal can run as // it. Only the keys are missing, and that is what the error says. const sshPublicKey = ssh.ok ? ssh.publicKey : null; await updateUser(params.userId, { osUser: account.osUser, osSshPublicKey: sshPublicKey }); - return { osUser: account.osUser, sshPublicKey, error: ssh.ok ? null : ssh.error }; + // SSH first if both failed: no keys is the more consequential of the two. + const error = !ssh.ok ? ssh.error : !shell.ok ? shell.error : null; + return { osUser: account.osUser, sshPublicKey, error }; } diff --git a/src/servers/os-user-shell.ts b/src/servers/os-user-shell.ts new file mode 100644 index 00000000..0b075db5 --- /dev/null +++ b/src/servers/os-user-shell.ts @@ -0,0 +1,143 @@ +import { readFile } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { osUserHome } from './os-user'; + +// The shell a member gets when they open the terminal. +// +// ── Why there is a template at all ── +// +// A brand-new Linux account opens a shell with no prompt worth the name, no history, no completion and no +// colour — `useradd` copies /etc/skel, which on Ubuntu is a bash rc for a bash user. The account's shell is +// zsh, so it gets nothing. "Their own account" should not mean "a worse terminal than the owner's". +// +// ── What it is ── +// +// `shell-skel/zshrc` → `~/.zshrc`, and the platform's own `scripts/starship.toml` → `~/.config/starship.toml` +// so a member's prompt is the same one the owner's install deploys. That file is the single source for both: +// setup.sh copies it for the owner and this copies it for everybody else, so the two cannot drift. +// +// ── Never clobbering someone's edits ── +// +// Written only when the file is ABSENT. That makes this safe to re-run, which matters because the retry +// button reprovisions an account whenever the owner presses it, and losing somebody's shell configuration to +// a maintenance action would be indefensible. +// +// The cost is that improving a template reaches new accounts only. That is the right way round, and +// `~/.zshrc.local` — sourced last, never written — is the pressure valve: it is where your own configuration +// goes, so nothing a future template does can reach it. + +/** Where the templates live, relative to this file. */ +const SKEL_DIR = join(import.meta.dir, 'shell-skel'); +/** The prompt config the owner's own install uses — one file, both audiences. */ +const STARSHIP_SRC = join(import.meta.dir, '../../scripts/starship.toml'); + +type SudoResult = { ok: boolean; out: string }; + +async function sudo(args: string[]): Promise { + const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' }); + const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() }; +} + +/** The file's current contents, or null when it does not exist. Read as root: the home is 700 and theirs. */ +async function currentContents(path: string): Promise { + const result = await sudo(['cat', path]); + return result.ok ? result.out : null; +} + +/** + * Write one template file into the account's home, unless they have edited it. + * + * Returns what happened, so the caller can report "seeded" separately from "left alone" — an owner pressing + * retry should not be told it rewrote files it deliberately did not touch. + */ +async function installTemplate(params: { + content: string; + dest: string; + uid: number; + gid: number; +}): Promise<{ ok: true; wrote: boolean } | { ok: false; error: string }> { + // Written only when ABSENT. Not "absent or identical to the template" — I wrote that first and it is + // meaningless: if the file already matches there is nothing to write, and if it differs we cannot tell an + // edit from an older template version, so the only safe reading of "differs" is "theirs". Updating a + // template therefore reaches new accounts only, which is the right trade for never eating someone's config. + if ((await currentContents(params.dest)) !== null) return { ok: true, wrote: false }; + + const dir = await mkdtemp(join(tmpdir(), 'officer-skel-')); + const staged = join(dir, 'staged'); + try { + await writeFile(staged, params.content, { mode: 0o600 }); + + // The parent, explicitly and with the right owner. `install -D` creates missing parents but applies + // `-o`/`-g` only to the FILE — measured: it left `~/.config` as root:root, so the member could read their + // own starship.toml and could not write anything else into `.config`, which is where half of a shell's + // tools want to keep state. A single wrong-owner directory in a home is the kind of thing that surfaces + // weeks later as one tool mysteriously failing. + const parent = dirname(params.dest); + const madeParent = await sudo([ + 'install', + '-d', + '-o', + String(params.uid), + '-g', + String(params.gid), + '-m', + '700', + parent, + ]); + if (!madeParent.ok) return { ok: false, error: `could not create ${parent}: ${madeParent.out}` }; + + const written = await sudo([ + 'install', + '-o', + String(params.uid), + '-g', + String(params.gid), + '-m', + '644', + staged, + params.dest, + ]); + if (!written.ok) return { ok: false, error: `could not write ${params.dest}: ${written.out}` }; + return { ok: true, wrote: true }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +export type ShellSeedResult = { ok: true; wrote: string[]; kept: string[] } | { ok: false; error: string }; + +/** + * Give the account the standard shell configuration. + * + * Reports `wrote` and `kept` separately so a reprovision can say it left someone's edited files alone rather + * than implying it rewrote them. + */ +export async function seedShellConfig(params: { email: string; uid: number; gid: number }): Promise { + const home = osUserHome(params.email); + + let zshrc: string; + let starship: string; + try { + zshrc = await readFile(join(SKEL_DIR, 'zshrc'), 'utf-8'); + starship = await readFile(STARSHIP_SRC, 'utf-8'); + } catch (ex) { + return { ok: false, error: `could not read the shell templates: ${ex instanceof Error ? ex.message : ex}` }; + } + + const wrote: string[] = []; + const kept: string[] = []; + + for (const [content, dest] of [ + [zshrc, join(home, '.zshrc')], + [starship, join(home, '.config/starship.toml')], + ] as const) { + const result = await installTemplate({ content, dest, uid: params.uid, gid: params.gid }); + if (!result.ok) return result; + (result.wrote ? wrote : kept).push(dest.replace(`${home}/`, '~/')); + } + + return { ok: true, wrote, kept }; +} diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts index 17713c11..6f33127d 100644 --- a/src/servers/os-user.ts +++ b/src/servers/os-user.ts @@ -45,6 +45,18 @@ export function osUserNameFor(params: { username: string | null; email: string } return toShellUsername('', params.email).slice(0, MAX_USERNAME); } +/** + * The login shell a new account gets: zsh where it exists, bash otherwise. + * + * Resolved from `/etc/shells`-style existence rather than from this process's environment. See the call site. + */ +async function defaultShell(): Promise { + for (const candidate of ['/usr/bin/zsh', '/bin/zsh', '/bin/bash']) { + if (existsSync(candidate)) return candidate; + } + return '/bin/sh'; +} + /** The account's home as passwd records it, or null if it has none / does not exist. */ async function passwdHome(osUser: string): Promise { const result = await run(['getent', 'passwd', osUser]); @@ -213,9 +225,13 @@ export async function ensureOsUser(params: { email: string; username: string | n } if (!ids) { - // `-M` because provisionUserDirs already made the directory, and letting useradd create it would - // copy /etc/skel in as root-owned. `-s` explicitly: /etc/default/useradd here says /bin/sh, and a - // member opening a terminal should get the same shell everyone else gets. + // `-M` because provisionUserDirs already made the directory, and letting useradd create it would copy + // /etc/skel in as root-owned. + // + // The shell is chosen from what is INSTALLED, not from `process.env.SHELL`. That was the first version and + // it is wrong twice: this process is started by PM2, whose environment has whatever shell PM2 was launched + // from — often `/bin/sh` and sometimes nothing — so the member's shell depended on how the server happened + // to be started. zsh is what the platform's own setup installs and what the shell template targets. const create = await run([ 'sudo', '-n', @@ -224,7 +240,7 @@ export async function ensureOsUser(params: { email: string; username: string | n home, '-M', '--shell', - process.env.SHELL ?? '/bin/bash', + await defaultShell(), osUser, ]); if (!create.ok) return { ok: false, error: `useradd failed: ${create.out}` }; diff --git a/src/servers/shell-skel/zshrc b/src/servers/shell-skel/zshrc new file mode 100644 index 00000000..1330dcb5 --- /dev/null +++ b/src/servers/shell-skel/zshrc @@ -0,0 +1,117 @@ +# Officer — default shell configuration. +# +# Written when your Linux account was created. It is yours: edit it freely. Officer only ever writes this +# file if it is missing or still byte-for-byte identical to the template, so your changes survive every +# reprovision. +# +# Deliberately depends on nothing but zsh. Starship, eza, nvim and bun are each used only if present, so +# this same file works on a minimal server and on a fully equipped one. + +# ── PATH ── +# $HOME-relative throughout. Anything hard-coded to one person's home is a template that only works for +# them, which is how the owner's own .zshrc grew three absolute paths. +export PATH="$HOME/bin:$HOME/.local/bin:/usr/local/bin:$PATH" +[ -d "$HOME/.bun/bin" ] && export PATH="$HOME/.bun/bin:$PATH" +[ -d "$HOME/.deno/bin" ] && export PATH="$HOME/.deno/bin:$PATH" +[ -d "$HOME/.cargo/bin" ] && export PATH="$HOME/.cargo/bin:$PATH" +[ -d "$HOME/.opencode/bin" ] && export PATH="$HOME/.opencode/bin:$PATH" +[ -d /opt/nvim-linux-x86_64/bin ] && export PATH="$PATH:/opt/nvim-linux-x86_64/bin" + +# ── History ── +# The bits oh-my-zsh would otherwise be pulled in to provide. Shared across concurrent shells, which +# matters here: a browser tab and an SSH session are often the same person in the same directory. +HISTFILE="$HOME/.zsh_history" +HISTSIZE=50000 +SAVEHIST=50000 +setopt SHARE_HISTORY # write and read as you go, not only at exit +setopt HIST_IGNORE_ALL_DUPS # keep one copy of a repeated command +setopt HIST_IGNORE_SPACE # a leading space keeps it out of history +setopt HIST_REDUCE_BLANKS +setopt EXTENDED_HISTORY # timestamps + +# ── Directories and globbing ── +setopt AUTO_CD # `..` and bare directory names change directory +setopt AUTO_PUSHD # every cd pushes, so `cd -` is a menu +setopt PUSHD_IGNORE_DUPS +setopt EXTENDED_GLOB +setopt INTERACTIVE_COMMENTS # `#` works when pasting a commented command + +# ── Completion ── +autoload -Uz compinit +# Cache the dump in the account's own home; -C skips the security check on a dump written today, which is +# the difference between an instant prompt and a visible pause on every new shell. +compinit -d "$HOME/.zcompdump" +zstyle ':completion:*' menu select +zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' # case-insensitive +zstyle ':completion:*' list-colors '' +setopt COMPLETE_IN_WORD +setopt ALWAYS_TO_END + +# ── Keys ── +# Emacs bindings explicitly: with EDITOR=nvim zsh would otherwise pick vi mode, which surprises anyone who +# did not ask for it. +bindkey -e +autoload -Uz up-line-or-beginning-search down-line-or-beginning-search +zle -N up-line-or-beginning-search +zle -N down-line-or-beginning-search +bindkey '^[[A' up-line-or-beginning-search # Up: history matching what is already typed +bindkey '^[[B' down-line-or-beginning-search +bindkey '^[[1;5C' forward-word # ctrl-arrow by word +bindkey '^[[1;5D' backward-word +bindkey '^[[3~' delete-char +bindkey '^[[H' beginning-of-line +bindkey '^[[F' end-of-line + +# ── Editor ── +if command -v nvim >/dev/null 2>&1; then + export EDITOR=nvim VISUAL=nvim SUDO_EDITOR=nvim + alias n='nvim' + alias vim='nvim' +elif command -v vim >/dev/null 2>&1; then + export EDITOR=vim VISUAL=vim +fi + +# ── Aliases ── +if command -v eza >/dev/null 2>&1; then + alias ls='eza --group-directories-first' + alias ll='eza -l --group-directories-first --git' + alias la='eza -la --group-directories-first --git' + alias lt='eza --tree --level=2' +else + alias ls='ls --color=auto --group-directories-first' + alias ll='ls -lh' + alias la='ls -lah' +fi +alias grep='grep --color=auto' +alias ..='cd ..' +alias ...='cd ../..' +alias sz='source "$HOME/.zshrc"' +command -v duf >/dev/null 2>&1 && alias duf='duf --only local' +command -v lazygit >/dev/null 2>&1 && alias lg='lazygit' + +# ── Prompt ── +# Starship if it is installed; zsh's own prompt with the same information if not. The fallback exists +# because a shell that opens with a broken prompt reads as a broken machine, and a minimal install has +# every right not to have starship on it. +if command -v starship >/dev/null 2>&1; then + eval "$(starship init zsh)" +else + autoload -Uz vcs_info + precmd_vcs_info() { vcs_info } + precmd_functions+=( precmd_vcs_info ) + zstyle ':vcs_info:git:*' formats ' %F{blue}%b%f' + setopt PROMPT_SUBST + PROMPT='%F{green}%n%f@%F{yellow}%m%f:[%~${vcs_info_msg_0_}] +%F{blue}❯%f ' +fi + +# ── Your own additions ── +# Sourced last so anything here wins. Officer never writes to it, so it is the safe place for your own +# configuration — and it is why this file can be replaced by a newer template without losing your work. +[ -f "$HOME/.zshrc.local" ] && source "$HOME/.zshrc.local" + +# Tool-installed completions and env, if they exist. Each of these appends itself to a shell rc when you +# install the tool; sourcing them conditionally keeps that working without hard-coding anyone's home. +[ -s "$HOME/.bun/_bun" ] && source "$HOME/.bun/_bun" +[ -s "$HOME/.deno/env" ] && source "$HOME/.deno/env" +[ -f "$HOME/.cargo/env" ] && source "$HOME/.cargo/env" diff --git a/src/servers/sidecar/pty/sessions.mjs b/src/servers/sidecar/pty/sessions.mjs index a34792e0..b6ce3747 100644 --- a/src/servers/sidecar/pty/sessions.mjs +++ b/src/servers/sidecar/pty/sessions.mjs @@ -94,10 +94,16 @@ function shellArgv(osUser) { '--init-groups', '--reset-env', '--', - 'env', - 'COLORTERM=truecolor', - SHELL.command, - ...SHELL.args, + // THEIR login shell, from their passwd entry — not this sidecar's `$SHELL`, which is whatever PM2 was + // started with. `--reset-env` has already set SHELL from passwd, so the indirection through `sh -c` is + // what reads it; there is no shell in an argv to expand a variable otherwise. + // + // `-i` rather than `-l`: interactive is what makes zsh read ~/.zshrc, which is the file the shell + // template writes. COLORTERM does not survive --reset-env and is restated here — it is how programs + // decide they may emit 24-bit colour. + 'sh', + '-c', + 'COLORTERM=truecolor exec "$SHELL" -i', ], }; }