#!/usr/bin/env bun /** * `bun run doctor` — check the invariants that provisioning cannot check for itself. * * ── Why ── * * Every step in `provisionOsAccount` is deliberately non-fatal, so "nothing reported an error" is much * weaker than it sounds. The 2026-08-19 case: a fresh server, an Admin granted files, chat and terminal, * every named ACL entry correct, provisioning clean — and the account reached nothing, because a later * `chmod 700` had recomputed the ACL mask to `---`. It was found by a person, three days and one * confusing UI message later. * * This is the same posture as `assertPermissionTotality`: state the invariant, verify it, name the fix. * The difference is that this reports rather than refusing to boot — a broken member must not stop the * platform serving everyone else. * * Read-only. It changes nothing, so it is safe on a production box. * * Exit 1 if anything is wrong, so it can gate the end of a setup run. */ import { getUsers, getAllRoleGrants, OWNER_USER_ID } from 'officerdb'; import { checkOsAccount } from '../src/servers/os-user-verify'; import { PERMISSIONS } from '../src/servers/permissions/registry'; const red = (s: string) => `\x1b[31m${s}\x1b[0m`; const green = (s: string) => `\x1b[32m${s}\x1b[0m`; const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; let problems = 0; const fail = (what: string, fix: string) => { problems++; console.log(`${red('✗')} ${what}`); console.log(` ${dim(fix)}`); }; const pass = (what: string) => console.log(`${green('✓')} ${what}`); console.log('\n── Runtime ──\n'); /** * bun is deliberately held at a series — `BUN_SERIES` in * scripts/setup/machine-setup/lib/dev.sh — while 1.4 is watched for a while. * * The setup script can pin the INSTALL and nothing more. `bun upgrade` takes no version * argument (it offers latest-stable or --canary and nothing else), so it always jumps to the * newest release and walks a pinned machine off the pin without saying so. This is the only * thing that notices, which is why a version check sits in a script otherwise about accounts. * * The wanted series is READ from `engines.bun` rather than repeated here, because a second * hardcoded copy is a second thing to drift. bun does not enforce that field — verified: an * install declaring `engines.bun: "1.9.x"` ran clean under 1.3.14, no warning — so the field is * the declaration and this is the enforcement. */ const pkg = (await Bun.file(new URL('../package.json', import.meta.url)).json()) as { engines?: { bun?: string }; }; const wantSeries = (pkg.engines?.bun ?? '').replace(/\.x$/, ''); const nowSeries = Bun.version.split('.').slice(0, 2).join('.'); if (!wantSeries) { fail( 'package.json declares no engines.bun', 'The setup scripts pin a bun series and nothing else records which one. Add it.', ); } else if (nowSeries === wantSeries) { pass(`bun ${Bun.version} ${dim(`(inside the pinned ${wantSeries} series)`)}`); } else { fail( `bun ${Bun.version} is outside the pinned ${wantSeries} series`, `Usually a 'bun upgrade', which always takes the newest release. Pin it back with ` + `'curl -fsSL https://bun.sh/install | bash -s bun-v', or raise ` + `engines.bun and BUN_SERIES together if ${nowSeries} is now wanted.`, ); } console.log('\n── Accounts ──\n'); const users = await getUsers(); for (const user of users) { if (user.id === OWNER_USER_ID) { // The owner runs AS the service user, in the service user's own home. There is no Linux account to // check and no ACL to get wrong — `osUser: null` is correct here, not a finding. pass(`${user.email} ${dim('(owner — runs as the service account)')}`); continue; } const check = await checkOsAccount(user.email, user.osUser ?? null); if (check.ok) { pass(`${user.email} ${dim(`(${user.role}, os_user=${check.osUser}, home readable)`)}`); } else { fail(`${user.email} (${user.role}) — ${check.problem}`, `home: ${check.home}`); } } console.log('\n── Grants that cannot take effect ──\n'); // A `confined` grant is silently dropped for an account with no Linux user. That is correct behaviour and // completely invisible: the grant is in the table, the UI shows it ticked, and it resolves to nothing. const confinedKeys = new Set(PERMISSIONS.filter((p) => p.kind === 'confined').map((p) => p.key)); const grants = await getAllRoleGrants(); const unconfined = users.filter((u) => u.id !== OWNER_USER_ID && !u.osUser); if (!unconfined.length) { pass('every non-owner account has a Linux user'); } else { for (const user of unconfined) { const dropped = grants .filter((g) => g.role === user.role && confinedKeys.has(g.permission)) .map((g) => g.permission); if (!dropped.length) continue; fail( `${user.email} (${user.role}) holds ${dropped.join(', ')} — all dropped, because the account has no Linux user`, 'Fix: Settings → User management → provision, or POST /api/users//provision-linux', ); } } console.log(''); if (problems === 0) { console.log(green('No problems found.\n')); process.exit(0); } console.log(red(`${problems} problem${problems === 1 ? '' : 's'} found.\n`)); process.exit(1);