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>
This commit is contained in:
2026-08-11 19:43:55 +00:00
co-authored by Claude Opus 5
parent e4acf19a35
commit 71589aee99
6 changed files with 339 additions and 35 deletions
+20 -4
View File
@@ -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<string> {
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<string | null> {
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}` };