Files
platform/scripts/doctor.ts
T
pastilhasandClaude Fable 5 a356656a60 hold bun at the 1.3 series until 1.4 has been watched
bun 1.4.0 was published 2026-08-20 and https://bun.sh/install serves
whatever is newest, so machine-setup provisioning a box today would put a
two-day-old release under the platform and every pm2 app except
officer-pty. The owner asked for a temporary hold on 1.3.

The SERIES is pinned, not a patch — BUN_SERIES=1.3, resolved to the newest
1.3.x at install time so bug fixes inside the series still land, and no
1.3.14 to rot into "the version we happened to pick" the way setup_22.x
did for Node. The version reaches the installer as its documented first
positional argument (a release tag), and install_bun refuses to run with
an empty one: taking whatever bun.sh serves is the single outcome the pin
exists to prevent, so no bun at all is the better failure. Loud, and
officer-setup's pre-flight already stops on a missing bun.

Resolved from the npm registry rather than GitHub's releases API, which
is the obvious source and the wrong one — 60 unauthenticated requests an
hour per IP, and it returned 504 twice while this was written. An empty
answer installs nothing, so where it comes from is load-bearing.

The section now reads four states instead of two. "Installed" was one
answer covering a machine inside the pin, one behind it, and one past it;
past it is the case worth catching, since that is what bun upgrade leaves
and nothing else reports it. Above and below get different prose and both
offer to move; nothing is corrected silently.

What a setup script cannot cover: bun upgrade takes no version argument —
latest stable or --canary, verified against its own --help — so it always
jumps past the pin without saying so. Hence the doctor check, which reads
engines.bun rather than repeating the number, because a second hardcoded
copy is a second thing to drift. bun ignores engines (verified: an install
declaring 1.9.x ran clean under 1.3.14), so the field is the declaration
and doctor is the enforcement. Confirmed bun install --dry-run still
resolves frozen with the field present and bun.lock unchanged.

This host is already on 1.3.14 and inside the pin, so nothing moved here.
Also fixes three more stale process counts in the same block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:45:26 +00:00

125 lines
5.2 KiB
TypeScript

#!/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<newest ${wantSeries}.x>', 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/<id>/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);