Files
platform/src/servers/os-user-shell.ts
T
pastilhasandClaude Opus 5 71589aee99 a member's terminal looks like the owner's
A new Linux account opens a shell with nothing: useradd copies /etc/skel, which on Ubuntu
is a bash rc, and the account's shell is zsh — so it got no prompt, no history, no
completion, no colour. "Their own account" should not mean a worse terminal than the
owner's.

src/servers/shell-skel/zshrc is the template, and scripts/starship.toml is reused rather
than copied: setup.sh already deploys it for the owner, so one file serves both audiences
and they cannot drift. Seeded by provisionOsAccount, which means the retry button applies
it to accounts that already exist — no delete-and-recreate.

The template depends on nothing but zsh. Starship, eza, nvim, bun, deno and cargo are each
used only if present, and every path is $HOME-relative — the owner's own .zshrc has three
absolute /home/pastilhas paths in it, which is exactly what a template must not inherit.
Without starship it falls back to a zsh prompt showing the same information, because a
shell that opens with a broken prompt reads as a broken machine.

Never overwrites: written only when the file is ABSENT. ~/.zshrc.local is sourced last and
never written, so there is somewhere to put your own config that no future template can
reach.

Three fixes found by running it:

- install -D creates missing parents but applies -o/-g only to the FILE, so ~/.config came
  out root:root — readable but not writable by its owner, which would have surfaced weeks
  later as one tool mysteriously failing. The parent is now created explicitly.
- useradd took its shell from process.env.SHELL, which under PM2 is whatever PM2 was
  launched from. A member's shell depended on how the server happened to be started. Now
  chosen from what is installed: zsh, else bash.
- the pty sidecar spawned ITS $SHELL for a member, not theirs. It now execs their passwd
  shell via sh -c, so the login shell in /etc/passwd is the one they get.

starship moves out of the light-profile skip. 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
what every member gets. Leaving starship out meant the fallback prompt on exactly the
installs most likely to have members. oh-my-zsh, eza and lazygit stay full-only.

Verified in a real member shell: zsh from passwd, HISTFILE in their own home, eza-backed
ll, starship active, EDITOR=nvim, and an edit to .zshrc surviving a reprovision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:43:55 +00:00

144 lines
6.0 KiB
TypeScript

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<SudoResult> {
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<string | null> {
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<ShellSeedResult> {
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 };
}