Files
platform/src/servers/os-user-shell.ts
T
pastilhasandClaude Opus 5 9c353f5f0d move host setup into scripts/setup/
scripts/ was holding two unrelated kinds of thing: install-this-machine, and run-this-occasionally. The
eight installers now live in scripts/setup/; what stays at the top level is the build steps (gen-index,
prebuild, build/) and the two maintenance scripts (reindex-music, rebuild-soulseek-tree).

The move is not just a rename. Three of these derive the repo root from their own location:

  setup.sh:51            PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
  setup_mac_light.sh:51  same
  cleanup-desktop.sh:134 ENV_FILE="$(dirname "$0")/../.env"

Left alone, all three would now resolve to scripts/ — and nothing downstream complains. PROJECT_DIR is
where .env is written, where `bun install`, `gen:index` and `db:push` run, and what pm2 is pointed at, so
a fresh install would have quietly provisioned scripts/ and reported success. cleanup-desktop.sh fails
the other way: it would find no .env, print "No .env — skipping", and leave the real VNC_PASSWORD in the
real file. All three are now `../..` with a comment saying why the level matters.

provision-user-dirs.ts imports data-path.ts relatively; that one tsgo caught.

Also disambiguated `setup.sh` where it had become two files. app-store/templates/<name>/setup.sh is a
per-sidecar installer with its own contract, and preflight.ts + docs/sidecar-app-store.md discussed both
in the same paragraph. The host one is now spelled with its full path at those sites.

Verified: bash -n on all six shell scripts, tsgo clean, os-user tests pass, both derivations resolve to
the repo root, starship.toml still resolves from os-user-shell.ts, and provision-user-dirs.ts runs under
DRY_RUN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:02:19 +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/setup/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/setup/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 };
}