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 }; }