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>
144 lines
6.0 KiB
TypeScript
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 };
|
|
}
|